openapi: 3.2.0 info: title: HonestHook Mastodon API version: 1.0.0 description: Profiles and posts from the major social networks, through one API. One key, one response shape; rate limits, retries and platform changes are on us. Clean JSON for your app or your AI agent. We read the exact field each platform publishes, and return null when none exists. Plus a historical trend archive that answers what was trending, which cannot be reconstructed after the fact. contact: url: https://honesthook.com license: name: Proprietary url: https://honesthook.com/docs servers: - url: https://honesthook.com security: - bearerAuth: [] tags: - name: Mastodon paths: /api/v1/mastodon/profile: get: operationId: mastodonProfile summary: Public profile on mastodon, with the exact follower count x-credits: 1 x-charges-without-result: false description: 'One public profile, normalised into the same envelope as every other platform. Source: the account''s own instance (official API, no key). Costs 1 credit; the answer is cached for an hour and a cache hit costs nothing (`cached: true`, `credits_used: 0`). **`user@instance`, and the instance is required.** Mastodon is federated: `Gargron` alone identifies nobody, because a different person can hold that name on every server. A handle without an instance is refused with 400 rather than completed with a default server — completing it would return somebody else''s profile with `success: true`. The instance must be a public DNS name: IP literals, ports and paths are refused. The handle comes back fully qualified, so the response value is a valid next request.' parameters: - name: handle in: query required: true description: '**`user@instance`, and the instance is required.** Mastodon is federated: `Gargron` alone identifies nobody, because a different person can hold that name on every server. A handle without an instance is refused with 400 rather than completed with a default server — completing it would return somebody else''s profile with `success: true`. The instance must be a public DNS name: IP literals, ports and paths are refused. The handle comes back fully qualified, so the response value is a valid next request.' schema: type: string pattern: ^[A-Za-z0-9._-]{1,30}@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,24}$ example: Gargron@mastodon.social responses: '200': description: Profile found content: application/json: schema: $ref: '#/components/schemas/ProfileEnvelope' '400': description: '`parametro_invalido` — the handle is missing or does not match this platform''s format. Nothing is charged.' content: application/json: schema: $ref: '#/components/schemas/ProfileEnvelope' '401': description: '`chave_invalida` — key missing, unknown or inactive.' content: application/json: schema: $ref: '#/components/schemas/ProfileEnvelope' '402': description: 'Out of credits. Two different types, and they mean different things: `sem_creditos` (both wallets are empty) and `teto_estourado` (the key has a monthly ceiling and hit it). Waiting does not help either one before the 1st — this is not a rate limit.' content: application/json: schema: $ref: '#/components/schemas/ProfileEnvelope' '404': description: '`nao_encontrado` — no such profile on that platform.' content: application/json: schema: $ref: '#/components/schemas/ProfileEnvelope' '451': description: '`bloqueado` — the platform refused us. The resource exists and YOUR key is fine; 403 would wrongly blame the caller.' content: application/json: schema: $ref: '#/components/schemas/ProfileEnvelope' '503': description: '`indisponivel` — this endpoint is switched off in the catalogue, or a dependency of ours is down. Your key is fine.' content: application/json: schema: $ref: '#/components/schemas/ProfileEnvelope' tags: - Mastodon /api/v1/mastodon/posts: get: operationId: mastodonPosts summary: Recent posts on mastodon x-credits: 1 x-charges-without-result: false description: 'Posts recentes do proprio perfil no Mastodon, via API oficial da instancia da conta (sem chave). O handle e SEMPRE usuario@instancia: o Mastodon e federado, e o nome sozinho nao identifica ninguem. Aceita ?limit= de 1 a 40, padrao 20; valor fora da faixa e recusado com 400 em vez de aparado -- a fonte apara em silencio, e quem pedisse 100 receberia 40 achando que a conta tem 40 posts. Boost (repasse de terceiro) NAO entra: sao 15 em cada 20 no feed cru, e o objeto de cima de um boost vem com texto vazio e metricas zeradas. Resposta a outra pessoa entra, porque foi a conta que escreveu. Devolve likes, replies, reposts e quotes. O corpo vem em DOIS campos: `text` ja limpo de HTML, e `text_html` com o original -- este ultimo e HTML de terceiro e precisa ser sanitizado antes de ir para uma pagina, sob risco de XSS. `title` e sempre null (status nao tem titulo) e `position` e sempre null (ordem cronologica nao e ranking).' parameters: - name: handle in: query required: true description: '**`user@instance`, and the instance is required.** Mastodon is federated: `Gargron` alone identifies nobody, because a different person can hold that name on every server. A handle without an instance is refused with 400 rather than completed with a default server — completing it would return somebody else''s profile with `success: true`. The instance must be a public DNS name: IP literals, ports and paths are refused. The handle comes back fully qualified, so the response value is a valid next request.' schema: type: string pattern: ^[A-Za-z0-9._-]{1,30}@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,24}$ example: Gargron@mastodon.social - name: limit in: query required: false description: 'How many posts to return, 1 to 40. A value outside the range is refused with 400, not clamped: the source clamps in silence, and a caller asking for more than the maximum would conclude the account has fewer posts than it does.' schema: type: integer minimum: 1 maximum: 40 default: 20 responses: '200': description: Posts found. `has_more` is false and `next_cursor` is null on all three platforms today — there is no deep pagination. content: application/json: schema: $ref: '#/components/schemas/PostsEnvelope' '400': description: '`parametro_invalido` — the handle is missing, does not match this platform''s format, or `limit` is outside the range. Nothing is charged.' content: application/json: schema: $ref: '#/components/schemas/PostsEnvelope' '401': description: '`chave_invalida` — key missing, unknown or inactive.' content: application/json: schema: $ref: '#/components/schemas/PostsEnvelope' '404': description: '`nao_encontrado` — no such account. Nothing is charged.' content: application/json: schema: $ref: '#/components/schemas/PostsEnvelope' tags: - Mastodon components: schemas: PostsEnvelope: type: object required: - success - platform - endpoint - data - error - credits_used - cached - request_id description: 'Same envelope as every other route. `data.items` carries the posts the account itself wrote — reposts and boosts of other people are excluded by authorship. `has_more` is false and `next_cursor` is null on all three platforms today: there is no deep pagination, and the fields are present so the shape does not change on the day there is.' properties: success: type: boolean platform: type: string endpoint: type: string data: type: - object - 'null' properties: author: $ref: '#/components/schemas/Author' items: type: array items: $ref: '#/components/schemas/PostItem' has_more: type: boolean next_cursor: type: - string - 'null' error: type: - object - 'null' credits_used: type: integer cached: type: boolean request_id: type: string ProfileEnvelope: type: object required: - success - platform - endpoint - data - error - credits_used - credits_remaining - cached - request_id description: Identical shape on success and on error, so a client writes one parser. `success` is the only field that decides; `data` and `error` are mutually exclusive and the other is null, never absent. properties: success: type: boolean platform: type: string endpoint: type: string data: type: - object - 'null' properties: author: $ref: '#/components/schemas/Author' items: type: array items: type: object has_more: type: boolean next_cursor: type: - string - 'null' error: type: - object - 'null' properties: type: type: string enum: - parametro_invalido - chave_invalida - sem_creditos - teto_estourado - nao_encontrado - bloqueado - indisponivel description: Branch on this, never on the message text. message: type: string details: type: - object - 'null' credits_used: type: integer credits_remaining: type: - integer - 'null' description: 'THE SUM: purchased balance plus what is left of this month''s free allowance. This is the only number that answers "can I call again" — it is not 0 while you can still call. null means we could not read it, which is not the same as zero.' credits_purchased_remaining: type: - integer - 'null' description: Bought credits. These never expire. free_credits_remaining: type: - integer - 'null' description: What is left of this month's free allowance. free_credits_total: type: - integer - 'null' description: The monthly free allowance for this key. free_credits_reset_at: type: string format: date-time description: 'When the free allowance resets: 00:00 UTC on the 1st. It is here so the sum dropping at the turn of the month is expected rather than a surprise.' cached: type: boolean description: '`true` is a promise that we did NOT charge: it always comes with `credits_used: 0`.' request_id: type: string Author: type: object description: 'The same twelve names on every platform. A field that does not exist there is null, never absent and never zero: zero is a fact about the account, null is a fact about our reading.' properties: id: type: - string - 'null' description: Stable identifier on that platform. handle: type: - string - 'null' name: type: - string - 'null' bio: type: - string - 'null' avatar_url: type: - string - 'null' followers: type: - integer - 'null' following: type: - integer - 'null' posts_count: type: - integer - 'null' verified: type: - boolean - 'null' is_private: type: - boolean - 'null' external_url: type: - string - 'null' url: type: - string - 'null' PostItem: type: object properties: id: type: string url: type: - string - 'null' text: type: - string - 'null' text_html: type: - string - 'null' description: Mastodon only. Third-party HTML, unsanitised — sanitise before rendering or you have an XSS hole. `text` is the same content already stripped. title: type: 'null' description: 'Always null: a status has no title.' position: type: 'null' description: 'Always null: chronological order is not a ranking.' published_at: type: - string - 'null' format: date-time thumbnail_url: type: - string - 'null' description: Null on bluesky and mastodon — the image embed has not been measured on a large enough sample to promise a shape. metrics: type: object properties: likes: type: - integer - 'null' replies: type: - integer - 'null' reposts: type: - integer - 'null' quotes: type: - integer - 'null' description: 'Null on threads: the source publishes none.' securitySchemes: bearerAuth: type: http scheme: bearer description: 'API key, sent as `Authorization: Bearer hk_live_...`. Quota is monthly and renews on the 1st. Unused quota does not roll over and does not expire mid-cycle — you get the whole month.'