PenomalySign in

Penomaly API v1

Email marketing for indie authors. This API exists so services like BookFunnel can add readers to an author's mailing list on their behalf.

Three endpoints. No SDK, no webhooks, no OAuth dance. If something here is awkward to integrate against, say so and it will be changed.

Base URL: https://api.penomaly.com/v1

https://www.penomaly.com/api/v1 serves exactly the same routes and will keep working permanently, so nothing built against it needs changing. New integrations should use api.penomaly.com.

Do not use the bare `penomaly.com`. It 308-redirects to www, and most HTTP clients drop the request body when they follow a redirect. Your subscriber would arrive as an empty request and you would get a confusing 400 back.


Authentication

Every request needs an API key. The author generates one in Penomaly under Settings → API keys and pastes it into your integration.

Send it either way, both work:

Authorization: Bearer pnm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
X-API-Key: pnm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Keys start with pnm_live_. They do not expire. The author can turn one off at any time, and it stops working on the next request. A missing, unrecognised or turned-off key all return the same 401, deliberately.

To check a key the author has just pasted, call GET /lists. A 200 means the key is good and gives you the lists in the same round trip.


Responses

Success:

{ "data": { ... } }

Failure:

{ "error": { "code": "list_not_found", "message": "No list with that id on this account." } }

Branch on code, which is stable. message is for humans reading logs and may be reworded.

StatusMeaning
200Fine
201A new subscriber was created
400Something in the request is wrong
401Bad or missing API key
404No such list on this account
409The address cannot be added (bounced or reported spam)
429Too many requests, see Rate limits below

GET /lists

The author's mailing lists, so they can pick which one signups go to.

GET https://api.penomaly.com/v1/lists
Authorization: Bearer pnm_live_...
{
  "data": {
    "lists": [
      {
        "id": "3f2b1c8e-...",
        "name": "Newsletter",
        "description": null,
        "is_default": true,
        "requires_confirmation": false,
        "subscriber_count": 1284
      }
    ]
  }
}

Not paginated. Authors have a handful of lists, not thousands.

requires_confirmation means the author has double opt-in turned on for that list. Readers added to it are created as pending and receive nothing until they confirm by email. Worth surfacing to the author when they choose a list, since it changes what happens after signup.


POST /subscribers

Add a reader to a list.

POST https://api.penomaly.com/v1/subscribers
Authorization: Bearer pnm_live_...
Content-Type: application/json
{
  "email": "reader@example.com",
  "list_id": "3f2b1c8e-...",
  "first_name": "Alex",
  "last_name": "Doe",
  "tags": ["arc-team"],
  "ip_address": "203.0.113.9",
  "timezone": "America/Chicago",
  "source": "bookfunnel",
  "custom_fields": {
    "Book Title": "The one they claimed",
    "Source Campaign": "Spring giveaway"
  }
}
FieldRequiredNotes
emailyesLower-cased and trimmed on our side
list_idyesFrom GET /lists
first_namenoBlank or omitted never overwrites a name already on file
last_namenoSame
tagsnoMerged with any tags already on the reader, never replaced
ip_addressnoThe reader's IP, not your server's. See below
timezonenoIANA name, e.g. America/Chicago. See below
sourcenobookfunnel, form or api. Defaults to api
custom_fieldsnoAn object of extra data about the reader. Fields are created if the author does not have them. See below

Response, 201 for a new reader and 200 for one already on the list:

{
  "data": {
    "id": "9a7c...",
    "email": "reader@example.com",
    "status": "active",
    "created": true
  }
}

status is active, or pending when the list requires confirmation.

custom_fields_refused appears in data only when something was not stored. It is absent on an ordinary call, so if you see the key at all, read it.

It is idempotent

Posting the same reader twice is a success both times. The second one comes back 200 with "created": false. Retry freely on a timeout; you will not create duplicates and you will not get a 409 for a case that is not really a conflict.

About custom_fields

Added 13 September 2026.

Send anything else you know about the reader and it is stored against them: the book they claimed, which campaign brought them, whatever your own screen collects. Fields the author has not defined are created for them, so you do not have to ask them to set anything up first, and they appear in their Settings under the name you sent.

"custom_fields": {
  "Book Title": "The one they claimed",
  "Source Campaign": "Spring giveaway"
}

Use the name a human would read. We derive the storage key ourselves by lower-casing and replacing anything that is not a letter or a digit with an underscore, so Book Title and book_title are the same field. That is deliberate: without it, a field an author created by hand and the obvious name you send would be two columns each holding half the data.

Values are text. Numbers and booleans are accepted and stored as their text. Objects and arrays are ignored, because a field holds one value and flattening a nested one would be guessing.

Send the same names every time. Creating is idempotent, so there is no cost to repeating them, and a second spelling of the same thing is not a second field.

Limits. Fifty fields per account, and at most ten new ones created by any single request. Values are truncated at 500 characters.

Names that are refused, and what comes back:

ReasonMeaning
reservedThe name collides with a built-in, such as email, first_name, last_name, name or unsubscribe
unusableThe name has no letters or digits in it
capThe account is at one of the limits above

A refusal never fails the request. The reader is still added, everything else is still stored, and the response tells you what was left out:

{
  "data": {
    "id": "9a7c...",
    "email": "reader@example.com",
    "status": "active",
    "created": true,
    "custom_fields_refused": [
      { "name": "email", "reason": "reserved" }
    ]
  }
}

Losing a reader over a field name would be a far worse trade than losing the field, which is why this reports rather than rejects.

About timezone

Optional, and nothing uses it yet. It is accepted now so that if you already know a reader's timezone you can pass it, and the data accumulates from your first send rather than from whenever we build send-time scheduling.

An IANA name, like America/Chicago or Europe/London. If you collect it in a browser, Intl.DateTimeFormat().resolvedOptions().timeZone gives exactly this and needs no IP lookup.

Anything that does not look like a timezone name is ignored rather than rejected. Passing it will never fail a request.

About ip_address

Pass the reader's IP, the one that submitted your form. Do not pass your server's.

Under GDPR the author has to be able to show when and from where a reader consented, and that record cannot be reconstructed later. If you send your own server's address it would look like real evidence while being the same value for every reader the author ever gained, which is worse than sending nothing. Omit the field if you do not have the reader's IP.

Errors worth handling

`409 suppressed`. The address previously hard bounced or reported one of this author's emails as spam. It cannot be added by anyone. Please do not retry; adding it back is the one thing that genuinely damages an author's sending reputation.

A reader who merely unsubscribed is a different case. They are allowed to sign up again themselves, so that returns 200 or 201 normally, with status: "pending": changed 7 October 2026, the reader is sent a confirmation email, whatever the list's double opt-in setting, and is only back on the list once they click it. That stops anyone holding an API key from putting back a reader who opted out. You do not need to do anything special.

There is no plan-limit error. Until 7 October 2026 a full account answered 402 subscriber_limit_reached. New readers are now always accepted, and the author is warned in Penomaly instead.


GET /subscribers/status

Check whether an address is already on a list.

GET https://api.penomaly.com/v1/subscribers/status?email=reader@example.com&list_id=3f2b1c8e-...
Authorization: Bearer pnm_live_...

list_id is optional. With it, subscribed means "active on that specific list". Without it, "active on any of this author's lists".

{
  "data": {
    "id": "9a7c...",
    "email": "reader@example.com",
    "subscribed": true,
    "status": "active",
    "first_name": "Alex",
    "last_name": "Doe",
    "subscribed_at": "2026-03-04T10:22:31.000Z",
    "confirmed_at": null,
    "list_ids": ["3f2b1c8e-..."],
    "can_subscribe": true,
    "suppressed": false,
    "suppression_reason": null
  }
}

An address we have never seen returns 200, not 404:

{
  "data": {
    "email": "stranger@example.com",
    "subscribed": false,
    "status": "not_found",
    "can_subscribe": true,
    "suppressed": false,
    "suppression_reason": null
  }
}

status is one of active, pending, unsubscribed, bounced, complained, not_found.

can_subscribe is the useful one. When it is false, a POST /subscribers for that address will fail with 409, so you can hide or disable the newsletter tick box rather than showing the reader an error after the fact.


Rate limits

120 requests per minute, per API key. The same number MailerLite uses, so an integration written against theirs fits inside ours without changes.

Over the limit, every endpoint returns 429 with the stable code rate_limited, a Retry-After header saying how many seconds to wait, and X-RateLimit-Limit with the ceiling. The window is a fixed minute, so the wait is never longer than 60 seconds. Back off until Retry-After has passed and carry on; nothing is revoked and no state is lost.

If you plan to backfill a large list in one go, get in touch first and we can talk about a bulk path rather than you writing a throttle loop.


Not in v1

Not built, not planned to be hidden from you either:

  • Removing or unsubscribing a reader through the API
  • Webhooks for unsubscribes and bounces
  • Bulk import

Custom fields moved OFF this list on 13 September 2026. They are supported now; see custom_fields under POST /subscribers.

If your integration needs one of these, ask. The list above is a description of what exists, not a refusal.


Questions

Write to support@penomaly.com and it will get answered quickly. The API is young enough that changing it is cheap, so tell us what is wrong with it before you build a workaround.