FansquareAPI reference
Open the app

Resources

Changelog

Every change to the Fansquare API, newest first, including additive changes that need no new version.

Fansquare API versions are named after minerals, in alphabetical order. The Fansquare-Version header accepts the alias without accents (Amethyste) or the ISO date. A version remains supported for at least 18 months after the release of the next one.

Backward-compatible additions (no new mineral)

Additive changes: available on every version, they do not create a new mineral (older clients ignore what they do not know).

2026-10-13: Fixes

  • Webhook endpoints follow the creator scope of the key. A key restricted to some creators only sees, and only acts on, the endpoints that cover nothing but its own creators: an endpoint of the whole agency or one that lists another creator reads as 404 unknown_webhook_endpoint. On create and update, an empty allowed_creator_ids means the creators of the key, and a creator outside its scope returns 404 unknown_creator. Keys over the whole agency are unchanged.

  • Request bodies refused before the route return a 4xx, not a 500. Malformed JSON returns 400 validation_failed, a body over 256 KB 413 payload_too_large, a body that is not JSON 415 unsupported_media_type. An empty body sent with Content-Type: application/json is accepted on endpoints that take no body (DELETE, ping, rotate, replay).

  • Schema errors list a path on every item of errors (the existing fields stay).

  • Analytics windows west of UTC. A day in start / end is read in timezone, as documented: with timezone=America/New_York, 2026-07-01 used to start on June 30 local time. A day that does not exist (2026-02-30) is now refused with 400 validation_failed instead of rolling over to March 2.

  • Expired keys are refused from their exact expires_at, not up to 5 minutes later.

2026-10-13: Fansquare names

Offmy is now Fansquare. The API takes the new name everywhere it shows, and keeps accepting and sending the former names: an integration built before the rename keeps working unchanged.

  • Host: https://api.fansquare.so. https://developers.offmy.co serves the same API.

  • Version header: Fansquare-Version. Offmy-Version stays accepted; when both are sent, Fansquare-Version wins.

  • API keys: new keys start with fsq_live_. Keys starting with offmy_live_ stay valid.

  • Webhook deliveries carry Fansquare-Signature, Fansquare-Signature-Timestamp, Fansquare-Event-Type, Fansquare-Event-Id and Fansquare-Delivery-Id, and still carry the same values under their Offmy- names (Offmy-Signature and Fansquare-Signature hold the same signature).

  • Webhook signing secrets: new secrets start with fsq_whsec_. Existing offmy_whsec_ secrets keep signing.

  • documentation_url in version errors points to https://fansquare.so/api/versioning.

  • Unchanged until a future version: the type URI of errors (https://developers.offmy.co/errors/<slug>) and the User-Agent of webhook deliveries (Offmy-Webhooks/1.0).

2026-10-08: Fan tags and audience filters

The tags set on the fan card in Offmy ("VIP", "timewaster"…) are now exposed, and the fan list gains the filters of the CRM Audience. No new scope: read:fans is enough.

  • GET /v1/accounts/{account_id}/fans and GET …/fans/{fan_id}: new field tags, a list of { id, name } (empty when the fan has no tag).

  • GET /v1/tags: catalog of the agency's tags (id, name, color), in CRM order. Tags are agency-level: the creator scope of the key does not apply.

  • New optional filters on GET /v1/accounts/{account_id}/fans, combined with AND (OR within a list):

    • tag_ids: carries at least one of these tags;

    • exclude_tag_ids: carries none of these tags (fans without tags included);

    • has_tags (true/false): has at least one tag / has none;

    • statuses: active_subscriber, former_subscriber, interested;

    • min_spend_cents: total à la carte spend on this account, in euro cents (5000 = 50 €);

    • last_purchase_after / last_purchase_before: date of the last à la carte purchase (fans who never purchased do not match last_purchase_before);

    • is_online (true/false): current MYM presence.

  • "À la carte" = tips, private media, pushes. Subscriptions and refunded purchases are excluded.

  • Tag filters are checked: tag_ids with has_tags=false, or the same id in both tag_ids and exclude_tag_ids, return 400 validation_failed (the request could never return anything). An id that is not a tag of the agency returns 422 unknown_tag, with the offending ids in tag_ids.

2026-09-01: Sent messages (GET /v1/sent-messages)

New flat resource (agency resources, Offmy ids) and new scope read:messages: the send activity log of the employees, without the message content.

  • GET /v1/sent-messages (?user_ids, creator_ids, sources, start, end, cursor, limit): items id, sent_at, source (messaging/script; Offmy mass sends and native MYM pushes are not listed), sent_by_user_id (id from GET /v1/employees, null = unattributed send), creator_id, fan_id (MYM id), fan_username, is_private_media, price_eur (cents), sale_status (free/pending/purchased/refused), message_id (MYM id of the message, null when not reconciled yet) and media (MYM ids of the attached media, with their type).

  • The message content is never included: message_id and media are the pointers to fetch it directly from MYM with your own access.

  • Sorted from the most recent to the oldest, cursor pagination, rate limit 20/min.

  • Served from a statistics projection: freshness of about 15 minutes, history of about 24 months per connected account.

2026-07-22: Insight note concurrency token readable in the body

The concurrency token (formerly "ETag") only lived in the ETag header of the response. A header is invisible in most no-code tools (and it was missing from the generated docs, which only described it in prose). It is now emitted and accepted on both carriers, with the same value and the same semantics:

  • GET .../insight → { note, etag } in addition to the ETag header. In the body the token is bare, without quotes (the quotes are HTTP syntax).

  • PATCH .../insight accepts the token in the etag body field or in the If-Match header. Both are lenient (quoted, bare, W/ prefix). 400 validation_failed if both are sent and differ, 428 if neither is, 412 unchanged.

  • The PATCH response also returns { note, etag } + the ETag header.

Additive: integrations that already send If-Match do not change by a single byte. The generated docs now expose the ETag header and the If-Match parameter in OpenAPI (they were missing, hence misleading request examples).

2026-07-22: Every set filter is now plural

Restriction (not an addition). Rule that now applies across the whole API: a query parameter that selects a subset is plural and takes a CSV list, even for a single value (?creator_ids=abc, ?order_types=tip). This applies to identifiers and to enumerations alike. There is no singular twin anymore.

EndpointBeforeAfter
GET /v1/analytics/* (all 4)creator_id + creator_idscreator_ids
GET /v1/analytics/revenue/timeseriesuser_iduser_ids
GET /v1/orderscreator_idcreator_ids
GET /v1/ordersfan_idfan_ids
GET /v1/ordersorder_typeorder_types
GET /v1/accounts/{id}/fansassigned_user_idassigned_user_ids

Left untouched, on purpose: URL segments (/v1/creators/{creator_id}) identify a single resource, write bodies (PUT …/assignment { user_id }) write a single value, and response fields keep their natural cardinality (creator_id in an order item designates a creator, it is not a filter).

Why normalize rather than keep both forms: creator_ids is a strict superset of creator_id, so the singular brought nothing but a second spelling. Their coexistence on the analytics was even a trap, the two being merged as a union: ?creator_id=A&creator_ids=B,C returned the scope {A, B, C} with no error and no precedence.

Querystrings are now .strict(): an unknown parameter is a 400. This is what makes this renaming safe. Without it, ?creator_id=abc would have been silently ignored and the caller would have received the figures of all creators while believing they were filtered. A wrong result is worse than an error. An empty CSV (?creator_ids=) is a 400 for the same reason, rather than meaning "all".

Widened along the way: user_ids accepts several employees and counts the revenue attributed to any of them. No risk of double counting: app.order_attributions.order_id is UNIQUE.

2026-07-22: Analytics window caps

Restriction (not an addition): the analytics now cap the width of the window per bucket size. The cost of an aggregate is proportional to the number of orders in the window (the granularity only changes the GROUP BY, not the scan), so the cap applies to the window.

granularityMax window
hour7 days
day31 days
week / month365 days
(endpoints without granularity)365 days
GET /v1/analytics/creators90 days

/v1/analytics/creators is capped lower than the others: its subscriber counters are COUNT(DISTINCT), which must sort every row of the window instead of summing them as the scan goes. Measured on the largest agency: 31 d ≈ 185 ms, 90 d ≈ 323 ms, 365 d ≈ 8.2 s (the sort spills to disk). The three other endpoints only compute SUM and stay under a second over a full year.

Before: 730 days everywhere, hour ≤ 31 days. A window that is too wide returns 400 validation_failed; fetching more history takes several requests. The automatic default of granularity changes from "≤48h" to "≤2 days" for hour (only windows that cross a clock change switch bucket).

Shipped without a new mineral: the public API is in preview and has two active keys. A restriction of this kind on a widely installed API would go through a new mineral.

2026-07-21: Analytics

Ready-made revenue aggregates (no more downloading the whole /orders ledger to build a chart). Window: start/end are inclusive days interpreted in timezone (canonical IANA name Area/Location, UTC by default); amounts in EUR cents; affiliate revenue is grouped under affiliation.

  • GET /v1/analytics/revenue: total + by_type over the window.

  • GET /v1/analytics/revenue/timeseries: gap-filled buckets for charts, granularity (hour|day|week|month, automatic default), breakdown=creator, user_id filter (revenue attributed to an employee).

  • GET /v1/analytics/creators: revenue by type + subscriptions (new, renewal, paying_fans) per creator.

  • GET /v1/analytics/employees: attributed revenue per employee + unattributed_amount / former_employees_amount.

  • Common filters: creator_id, creator_ids (CSV, max 50), timezone.

  • New scope: read:analytics.

2026-07-17: Fan insight note

  • GET /v1/accounts/{account_id}/fans/{fan_id}/insight (scope read:insights) → { note } + ETag header.

  • PATCH /v1/accounts/{account_id}/fans/{fan_id}/insight (scope write:insights): body { note? }, If-Match header required (428 otherwise, 412 if the note changed since it was read). Optimistic concurrency + edit history.

  • New scopes: read:insights, write:insights.

Améthyste (2026-07-06)

Initial version.

  • Agency resources (Offmy ids): GET /v1/creators, GET /v1/creators/{creator_id}, GET /v1/employees, GET /v1/orders, GET /v1/orders/{order_id}.

  • Per-fan relations (MYM ids): GET /v1/accounts/{account_id}/fans, GET /v1/accounts/{account_id}/fans/{fan_id}, PUT|DELETE /v1/accounts/{account_id}/fans/{fan_id}/assignment, PUT|DELETE /v1/accounts/{account_id}/fans/{fan_id}/ai-lock.

  • Meta: GET /v1/_health, GET /status, GET /changelog, GET /v1/me, GET /v1/openapi.json, /docs.