Inkwave Help

Publisher API

Inkwave's REST API for your own servers, with API keys and scopes, every v1 endpoint, response shapes, limits, errors and curl examples.

What the API is for

The publisher API lets your own server code read and change one publication: list and add subscribers, unsubscribe them, tag them and set their custom fields, read your segments, published posts, website comments and their stats, and read how your audience and paid subscriptions are growing. Common uses are adding readers from your own signup form, keeping a CRM's labels in step with your tags, and sending revenue figures to an accounting or analytics tool.

The API can't delete anything, send or publish anything, or moderate comments. Do those in the Inkwave app.

It is a small, versioned REST API. Everything here is version 1, under /v1, and changes to it are added without breaking existing calls.

These Developers pages are reference for docs.inkwave.space only. They don't open in the help panel inside the Inkwave app.

Creating a key

  1. The owner or an admin opens Settings, API and clicks Create key.
  2. Give the key a name you'll recognise, such as "Website signup form", tick the scopes it needs, and choose when it expires.
  3. The key, beginning inkw_api_, is shown once. Inkwave keeps only a hash of it, so copy it then and store it as a secret on your server.

A key belongs to one publication, the one it was created in. Its scopes can be changed later from the key list without re-issuing it, and the change applies to its next request. Revoking a key is permanent and takes effect at once.

API keys are separate from MCP keys (inkw_mcp_…). An API key never works on the MCP server, and an MCP key never works on the API. Give each server or integration its own key, so revoking one doesn't cut off the rest.

The API needs the API access plan feature, the same one MCP uses.

Scopes

A key can do only what was ticked when it was created or last edited.

ScopeAllows
readGET /v1/publication: your publication's basic details.
subscribers:readListing subscribers and reading one subscriber, without their email address, and listing your tags and custom fields.
subscribers:emailAdds email addresses to subscriber results, including the members of a segment and the authors of comments, and lets you search by email. Needs subscribers:read too.
posts:readYour published posts, one post with its web HTML, and a post's stats.
audience:readAudience stats: growth, active and unsubscribed counts, list health.
audience:writeAdding subscribers and unsubscribing them. Needs subscribers:read too.
revenue:readPaid subscriber counts, MRR and ARR, new and churned paid subscribers, revenue collected, and your paid tiers and their prices. No card details, no Stripe ids and no email addresses.
tags:writeCreating subscriber tags, adding a tag to a subscriber or removing it, and setting or clearing a subscriber's custom field value. Needs subscribers:read too.
segments:readYour segments and their sizes, and the subscribers in one. Email addresses only with subscribers:email as well. Needs subscribers:read too.
comments:readWebsite comments on your published posts: the text, the post, when it was written, its status and the commenter's name. The commenter's email address only with subscribers:email as well.

Reading your tags and custom fields comes with subscribers:read, with no extra scope.

In Settings, API, ticking a scope that needs subscribers:read ticks that too, and it stays ticked until you untick the scope that needs it.

Treat a key with subscribers:email like a subscriber export: anyone holding it can read your whole list.

Existing keys don't gain new scopes. revenue:read, tags:write, segments:read and comments:read are off on every key created before they existed, and off by default on a new key. To add one to a key you already use, choose Edit permissions from its menu in Settings, API. The key itself stays the same, so nothing needs changing on your server.

Authenticating

Send the key in the Authorization header of every request:

Authorization: Bearer inkw_api_…

The base URL is https://app.inkwave.space/api/v1.

Server to server only. Call the API from code you run, never from a web page or an app on a reader's device, where anyone could copy the key. The API sends no CORS headers and doesn't answer OPTIONS preflight requests, so browsers block calls made from a web page.

Responses

Every response is JSON. Dates are ISO 8601 in UTC, and ids are UUIDs.

KindShape
One item{ "data": { … } }
A list{ "data": [ … ], "next_cursor": "…" }
An error{ "error": { "code": "…", "message": "…" } }

Lists are paged with a cursor. Pass the next_cursor you were given as cursor to get the next page. When next_cursor is null, there are no more pages. limit sets the page size, from 1 to 100, 25 by default.

Endpoints

Method and pathScopeWhat it does
GET /v1/publicationreadYour publication: id, name, url, subscriber_count, plan and created_at.
GET /v1/subscriberssubscribers:readLists subscribers. Takes cursor, limit, status and search.
GET /v1/subscribers/:idsubscribers:readOne subscriber: status, tags, custom fields and when they joined.
POST /v1/subscribersaudience:writeAdds a subscriber, or subscribes again one who left.
POST /v1/subscribers/:id/unsubscribeaudience:writeUnsubscribes a subscriber.
GET /v1/postsposts:readYour published posts, newest first. Takes cursor and limit.
GET /v1/posts/:idposts:readOne published post, with its web HTML.
GET /v1/posts/:id/statsposts:readA post's sends, opens, clicks and web views.
GET /v1/stats/audienceaudience:readGrowth, active and unsubscribed counts and list health. Takes days, from 1 to 365, 30 by default.
GET /v1/revenuerevenue:readPaid subscribers, MRR and ARR, new and churned paid subscribers, revenue collected and a breakdown by tier. Takes days, from 1 to 365, 30 by default.
GET /v1/tiersrevenue:readYour paid tiers, their prices and how many subscribers each has.
GET /v1/tagssubscribers:readYour subscriber tags, by name.
POST /v1/tagstags:writeCreates a subscriber tag, or returns the one that already has that name.
POST /v1/subscribers/:id/tagstags:writeAdds one or more tags to a subscriber.
DELETE /v1/subscribers/:id/tags/:tagtags:writeRemoves a tag from a subscriber. The tag itself is kept.
GET /v1/custom-fieldssubscribers:readYour custom fields.
PUT /v1/subscribers/:id/custom-fields/:fieldtags:writeSets a subscriber's value for a custom field.
DELETE /v1/subscribers/:id/custom-fields/:fieldtags:writeClears a subscriber's value for a custom field. The field itself is kept.
GET /v1/segmentssegments:readYour segments and how many subscribers each has.
GET /v1/segments/:id/subscriberssegments:readThe subscribers in a segment, newest first. Takes cursor and limit.
GET /v1/commentscomments:readWebsite comments on your published posts, newest first. Takes post_id, status, cursor and limit.

Subscriber status

Every subscriber has a status:

StatusMeaning
activeReceives your newsletter.
pending_confirmationWas sent a confirmation email and hasn't clicked it yet.
pending_approvalConfirmed, and waiting for you to approve them (private publications that approve readers).
unsubscribedLeft your list.
bouncedTheir address bounced, so email to them stopped.
complainedThey marked your email as spam, so email to them stopped.
web_onlyHas a reader account on your site but isn't on your email list.
acceptedOnly in the answer to POST /v1/subscribers from a key without subscribers:email. It means the request was accepted, and nothing more: see Adding a subscriber. It is never stored, and ?status= doesn't take it.

Listing subscribers

status keeps only subscribers in one of the statuses above. search finds subscribers by email address, so it needs the subscribers:email scope as well; without it the request answers 403. Email addresses are included in results only when the key has subscribers:email.

Adding a subscriber

POST /v1/subscribers takes a JSON body:

FieldRequiredDescription
emailyesThe reader's email address.
tagsnoSubscriber tags to add, each by its name (any case) or id.
custom_fieldsnoValues for your custom fields, each keyed by the field's name (any case) or id.
utm_sourcenoWhere the reader came from, stored as their acquisition source. Without it, the source is api.

Tags and custom fields must already exist in your publication. If any is unknown, the request answers 400 and nothing is written. One request counts as one change towards your daily limit on changes, however many tags and fields it sets.

What it answers depends on whether the key has subscribers:email.

Without subscribers:email, every successful request answers 201 with only the address and "status": "accepted":

{ "data": { "email": "reader@example.com", "status": "accepted" } }

There is no id and no real status, whether the address was new, already subscribed or had left. Otherwise a key that may only add people could find out who is on your list by trying addresses and reading the answers.

With subscribers:email, the answer is the subscriber, with its id. An address that was already an active subscriber answers 200, and any other answers 201. Its status tells you what happened:

  • active: your publication uses single opt-in, so they're subscribed straight away.
  • pending_confirmation: your publication uses double opt-in, or the address had unsubscribed before. Inkwave sends them a confirmation email, and they're subscribed once they click it. Only then does subscription.created fire.
  • pending_approval: your publication is private and approves readers, so they wait for you to approve them.

Either way, the tags and custom field values in the request are applied, to a new subscriber and to one who was already on your list.

Adding through the API follows the same rules as your public signup forms, with no way to skip them. Blocked addresses are refused (SIGNUP_BLOCKED), a private publication that doesn't take signups refuses new readers (FORBIDDEN), and while Inkwave has paused reader signups the request answers 503 (READER_SIGNUPS_PAUSED).

Unsubscribing

POST /v1/subscribers/:id/unsubscribe has the same effect as unsubscribing someone from the Inkwave app: they stop receiving email, your counts update, and subscription.deleted fires to your webhooks.

Posts

Each post in GET /v1/posts has id, title, subtitle, url, thumbnail_url, published_at and authors, a list of { name, kind }. GET /v1/posts/:id adds html, the post's web HTML, and styles_css, the CSS it needs to look as it does on your site.

Revenue

GET /v1/revenue summarises your paid subscriptions. days, from 1 to 365 and 30 by default, sets the window that the new, churned and collected figures count over. The other figures are as they stand now.

{
  "data": {
    "days": 30,
    "paid_subscribers": { "active": 412, "in_trial": 18, "cancelling": 7, "total": 430 },
    "recurring": [ { "currency": "usd", "mrr": 3842.5, "arr": 46110 } ],
    "new_paid_subscribers": 31,
    "churned_paid_subscribers": 9,
    "churn_rate_percent": 2.18,
    "revenue": [ { "currency": "usd", "amount_paid": 4120.75, "refunded": 18, "invoice_count": 455 } ],
    "tiers": [ { "id": "6f1c…", "name": "Supporter", "active": 380, "in_trial": 15, "mrr": 3420 } ]
  }
}
FieldMeaning
paid_subscribersPaid subscribers now. active are paying, in_trial are in a free trial, and total is the two together. cancelling are active subscribers who have cancelled and keep access until the end of the period they paid for.
recurringMonthly (mrr) and yearly (arr) recurring revenue. Only active subscriptions count, not trials. A yearly plan counts as a twelfth of its price towards mrr, and arr is mrr times 12. Your plan prices are in US dollars, so this is always one usd entry.
new_paid_subscribersPaid subscriptions, active or in trial, that started in the window.
churned_paid_subscribersPaid subscriptions that were cancelled or expired in the window.
churn_rate_percentChurn over the window, as a percentage.
revenueMoney invoiced in the window, one entry for each currency invoices were in: amount_paid, refunded and invoice_count. refunded is what has been refunded against those same invoices, the ones created in the window, whenever the refund happened; this is how the Paid subscriptions page in Inkwave counts it too. Currencies are never converted or added together.
tiersEach paid tier's active and in_trial subscribers and its mrr.

Amounts are in the currency's main unit, such as dollars, to two decimal places. There are no card details, Stripe ids or email addresses anywhere in it. To see who is paying, list subscribers with subscribers:read: each subscriber has is_paid and tier_name.

If your publication hasn't connected Stripe to take payments, the request answers 409 with the code CONNECT_NOT_ONBOARDED.

GET /v1/tiers lists your paid tiers, all at once, with no paging:

{
  "data": [
    {
      "id": "6f1c…",
      "name": "Supporter",
      "description": "Every post, plus the monthly deep dive.",
      "is_active": true,
      "prices": [
        { "id": "0d4e…", "interval": "monthly", "amount": 9, "currency": "usd", "is_active": true, "trial_days": 7 },
        { "id": "7a21…", "interval": "annually", "amount": 90, "currency": "usd", "is_active": true, "trial_days": 7 }
      ],
      "subscriber_count": 395,
      "active_subscribers": 380,
      "trialling_subscribers": 15
    }
  ]
}

is_active says whether the tier, or the price, is switched on in Inkwave. A price's interval is monthly or annually. Its id is Inkwave's own, not a Stripe id.

Tags

GET /v1/tags lists your subscriber tags, sorted by name, each with id, name, subscriber_count and created_at. It is a paged list (see Responses) and needs only subscribers:read.

POST /v1/tags creates a tag from a JSON body with its name, from 1 to 50 characters. Spaces at either end are trimmed.

{ "name": "Webinar 2026" }

A new tag answers 201. If a tag with that name already exists, in any case, the request answers 200 with that tag and creates nothing, so it is safe to call again with the same name. Either way, it counts as one change towards your daily limit on changes.

{ "data": { "id": "c2a9…", "name": "Webinar 2026", "subscriber_count": 0, "created_at": "2026-10-26T09:14:02.000Z" } }

POST /v1/subscribers/:id/tags adds from 1 to 20 tags to a subscriber. Name each tag by its name (any case) or its id:

{ "tags": ["Webinar 2026", "c2a9…"] }

Every tag must already exist. If any is unknown, the request answers 400 and nothing is written: create it first with POST /v1/tags. A tag the subscriber already has is skipped. The answer is 200 with the subscriber's id, all their tags after the change, and added, how many were new to them:

{ "data": { "id": "9b7e…", "tags": [ { "id": "c2a9…", "name": "Webinar 2026" } ], "added": 1 } }

DELETE /v1/subscribers/:id/tags/:tag removes one tag from that subscriber only. Name the tag by its id, or by its name, URL-encoded. It never deletes the tag, and other subscribers keep it. The answer says whether it was removed, which tag, and the subscriber's tags after the change:

{ "data": { "id": "9b7e…", "removed": true, "tag": { "id": "c2a9…", "name": "Webinar 2026" }, "tags": [] } }

Removing a tag the subscriber doesn't have answers 200 with "removed": false and changes nothing, so a retry is safe. A tag that doesn't exist in your publication answers 404.

Custom fields

GET /v1/custom-fields lists your custom fields, each with id, name and created_at. It is a paged list and needs only subscribers:read. To read a subscriber's values, get the subscriber with GET /v1/subscribers/:id.

PUT /v1/subscribers/:id/custom-fields/:field sets a subscriber's value for one field, named by its name (any case) or id, replacing any value they had:

{ "value": "Acme Ltd" }

value must be text, up to 2,000 characters. A number, a list or null answers 400 and nothing is written. To clear a value, use DELETE. A field that doesn't exist answers 404: create custom fields in the Inkwave app, under Tools, Custom fields. The answer is the subscriber's id, the field and its new value:

{ "data": { "id": "9b7e…", "field": { "id": "41d0…", "name": "Company" }, "value": "Acme Ltd" } }

DELETE /v1/subscribers/:id/custom-fields/:field clears the subscriber's value. The field itself is kept, with every other subscriber's value. cleared says whether there was a value to clear, so a retry is safe:

{ "data": { "id": "9b7e…", "field": { "id": "41d0…", "name": "Company" }, "value": null, "cleared": true } }

Segments

GET /v1/segments lists your segments, as a paged list. subscriber_count is counted when you ask, not stored.

{
  "data": [
    {
      "id": "e810…",
      "name": "Engaged readers",
      "description": "Opened at least three of the last five posts.",
      "type": "dynamic",
      "subscriber_count": 1288,
      "created_at": "2026-08-02T11:40:00.000Z"
    }
  ],
  "next_cursor": null
}

GET /v1/segments/:id/subscribers lists the subscribers in one segment, newest first, paged with cursor and limit like GET /v1/subscribers. Each subscriber has the same fields as in GET /v1/subscribers, and the email address is included only when the key also has subscribers:email. A segment that isn't in your publication answers 404.

Comments

GET /v1/comments lists the comments readers left on your published posts on your website, newest first. Comments on drafts and on posts that were taken down aren't included, and deleted comments never are.

ParameterDescription
post_idOnly comments on this post. Get post ids from GET /v1/posts.
statusOnly comments with this status: visible, pending (waiting for your approval) or hidden. Without it, all three are listed.
cursor, limitPaging, as for every list.
{
  "data": [
    {
      "id": "a4f2…",
      "post_id": "5c19…",
      "parent_id": null,
      "body": "This changed how I plan my week.",
      "status": "visible",
      "like_count": 4,
      "created_at": "2026-10-25T18:02:11.000Z",
      "author": { "name": "Sam Reader", "subscriber_id": "9b7e…" }
    }
  ],
  "next_cursor": null
}

parent_id is the first comment of the thread a reply belongs to, or null for a top-level comment. The author's name and subscriber_id can be null. author includes email only when the key also has subscribers:email.

A post_id that isn't a valid id, or a status of deleted or anything else not listed above, answers 400. A post_id for a draft, a post that was taken down or another publication's post answers 404.

The API only reads comments. To hide, approve or delete one, use the Inkwave app: see Website comments.

Limits

LimitApplies toWhen it is reached
60 requests a minute per keyEvery request429 with the code RATE_LIMITED and a Retry-After header, in seconds
Requests per day per publication, set by your plan: Launch 2,000, Scale 20,000, Max 100,000Every request, across all the publication's API keys429 with the code QUOTA_EXCEEDED, and a Retry-After header counting down to 00:00 UTC, when it resets
Changes per day per publicationEach request that changes something: adding or unsubscribing a subscriber, creating a tag, adding or removing a subscriber's tags, and setting or clearing a custom field value. One request is one change.429 with the code WRITE_LIMIT_EXCEEDED

A change is counted when the request arrives, before it runs. A write that is then refused, such as one naming an unknown tag, still counts towards the day's changes.

Errors

StatusCodeWhen
400VALIDATION_ERRORThe request is invalid, such as a malformed email address, an unknown tag in a request body, a custom field value that isn't text, or days outside 1 to 365. The message says what to fix, and nothing was written.
401UNAUTHORIZEDNo key, a malformed key, or a key that is revoked, expired or unknown. The answer is the same in every case, so it never tells anyone which keys exist.
403FEATURE_NOT_IN_PLANYour plan doesn't include API access. Your keys are kept and work again after an upgrade.
403INSUFFICIENT_SCOPEThe key lacks the scope this request needs. The message names the scope.
403SIGNUP_BLOCKEDThe address you tried to add is blocked.
403FORBIDDENYour publication is private and doesn't take this signup.
404NOT_FOUNDThe subscriber, post, segment, or the tag or custom field named in the path, doesn't exist in this publication, or the post isn't published.
409CONNECT_NOT_ONBOARDEDGET /v1/revenue only: your publication hasn't connected Stripe to take payments.
429RATE_LIMITED, QUOTA_EXCEEDED, WRITE_LIMIT_EXCEEDEDA limit was reached.
500INTERNAL_ERRORSomething went wrong on our side. Try again later.
503READER_SIGNUPS_PAUSEDInkwave has paused reader signups for a while. Try again later.

Any other 5xx means something failed inside Inkwave. Before retrying a write, read the current state, because it may have taken effect.

Examples

Read your publication:

curl https://app.inkwave.space/api/v1/publication \
  -H "Authorization: Bearer $INKWAVE_API_KEY"

List subscribers, 50 at a time:

curl "https://app.inkwave.space/api/v1/subscribers?limit=50" \
  -H "Authorization: Bearer $INKWAVE_API_KEY"

Get the next page with the next_cursor from the last answer:

curl "https://app.inkwave.space/api/v1/subscribers?limit=50&cursor=NEXT_CURSOR" \
  -H "Authorization: Bearer $INKWAVE_API_KEY"

Add a subscriber from your own signup form:

curl -X POST https://app.inkwave.space/api/v1/subscribers \
  -H "Authorization: Bearer $INKWAVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "reader@example.com", "tags": ["website"], "utm_source": "homepage"}'

Unsubscribe someone:

curl -X POST https://app.inkwave.space/api/v1/subscribers/SUBSCRIBER_ID/unsubscribe \
  -H "Authorization: Bearer $INKWAVE_API_KEY"

Read audience stats for the last 90 days:

curl "https://app.inkwave.space/api/v1/stats/audience?days=90" \
  -H "Authorization: Bearer $INKWAVE_API_KEY"

Read revenue, with new and cancelled paid subscriptions over the last 7 days:

curl "https://app.inkwave.space/api/v1/revenue?days=7" \
  -H "Authorization: Bearer $INKWAVE_API_KEY"

Create a tag, then add it to a subscriber:

curl -X POST https://app.inkwave.space/api/v1/tags \
  -H "Authorization: Bearer $INKWAVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Webinar 2026"}'

curl -X POST https://app.inkwave.space/api/v1/subscribers/SUBSCRIBER_ID/tags \
  -H "Authorization: Bearer $INKWAVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tags": ["Webinar 2026"]}'

Remove that tag from the subscriber, keeping the tag (encode a name with spaces):

curl -X DELETE "https://app.inkwave.space/api/v1/subscribers/SUBSCRIBER_ID/tags/Webinar%202026" \
  -H "Authorization: Bearer $INKWAVE_API_KEY"

Set a subscriber's custom field, then clear it:

curl -X PUT https://app.inkwave.space/api/v1/subscribers/SUBSCRIBER_ID/custom-fields/Company \
  -H "Authorization: Bearer $INKWAVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value": "Acme Ltd"}'

curl -X DELETE https://app.inkwave.space/api/v1/subscribers/SUBSCRIBER_ID/custom-fields/Company \
  -H "Authorization: Bearer $INKWAVE_API_KEY"

List the subscribers in a segment:

curl "https://app.inkwave.space/api/v1/segments/SEGMENT_ID/subscribers?limit=100" \
  -H "Authorization: Bearer $INKWAVE_API_KEY"

List the comments waiting for approval on one post:

curl "https://app.inkwave.space/api/v1/comments?post_id=POST_ID&status=pending" \
  -H "Authorization: Bearer $INKWAVE_API_KEY"

On this page