Tumblr (tumblr)
Tumblr is an OAuth 2.0 provider with a two-step connect flow: authorize the app, then choose which blog the channel should post to. Posts use Tumblr’s Neue Post Format (NPF) content blocks, and media is streamed as a multipart upload (the JSON body is one part, each attachment another). One Tumblr account can become several channels, one per blog.
- Rust source:
crates/rustiz-providers/src/tumblr.rs - Upstream port:
tumblr.provider.ts - Editor: Normal (plain text)
- Connection mode:
oauth - Post-connect step: yes (
is_between_steps = true)
Overview
| Property | Value |
|---|---|
| Identifier | tumblr |
| Display name | Tumblr |
| Connection mode | oauth (authorization code, refresh token) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | No. supports_comments = false; only the first PostDetails is published; extra parts are not supported |
| Pending / async publishing | No. supports_pending = false; returns PostStatus::Completed |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | No. supports_mentions = false |
is_between_steps | true — the OAuth login is not enough; the blog must be selected with pages + channelSetupTool |
convert_to_jpeg | false |
max_concurrent_job | 3 |
Character limit (max_length) | 32768 characters |
| Text block limit | 4096 characters per text block (long text is chunked) |
| API | Tumblr API v2, NPF post creation |
Server Environment Variables
required_env() returns exactly two variables:
#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
&["TUMBLR_CLIENT_ID", "TUMBLR_CLIENT_SECRET"]
}
}
required_env_alternatives() is not overridden. If either is missing,
provider_status refuses to generate a login URL and tells you to register
the redirect URI.
| Variable | Required | Default | Purpose |
|---|---|---|---|
TUMBLR_CLIENT_ID | Yes | none | Tumblr app’s OAuth Consumer Key. |
TUMBLR_CLIENT_SECRET | Yes | none | Tumblr app’s OAuth Consumer Secret. |
FRONTEND_URL | Yes, in practice | http://localhost:5000 | Builds the OAuth redirect URI. Must be the public origin Tumblr can reach, with no trailing slash (the code trims one). The callback is served at /integrations/social/tumblr. |
TUMBLR_BASE_URL | No | https://api.tumblr.com/v2 | Overrides the API origin. Include the /v2 path: the override replaces the whole base and the code appends /oauth2/token, /user/info, /blog/.... |
TUMBLR_WEB_BASE_URL | No | https://www.tumblr.com | Overrides the web origin used to build /oauth2/authorize. |
ALLOW_PRIVATE_NETWORK | No | false | Only relevant if you point a base URL at a local mock; not needed for real Tumblr. |
required_env() does not include FRONTEND_URL, but an OAuth redirect
cannot work without it. The default produces
http://localhost:5000/integrations/social/tumblr, which is fine only for a
local browser login.
Step-by-Step Developer App Setup
-
Sign in to Tumblr and open https://www.tumblr.com/oauth/apps.
-
Click Register application (or Register a new application).
-
Fill in:
-
Application name — required, for example
Rustiz. -
Application description — required.
-
Website — required; your product or instance URL.
-
Callback URL / Redirect URI — required. Enter exactly:
{FRONTEND_URL}/integrations/social/tumblrFor example
https://social.example.com/integrations/social/tumblr. Do not add a trailing slash. The path is generated byformat!("{}/integrations/social/tumblr", config.frontend_url())andfrontend_url()strips any trailing slash fromFRONTEND_URL.
-
-
Save the app. Tumblr shows an OAuth Consumer Key and an OAuth Consumer Secret (the secret is behind Show secret key).
-
Put them in the server environment:
TUMBLR_CLIENT_ID="your-oauth-consumer-key" TUMBLR_CLIENT_SECRET="your-oauth-consumer-secret" FRONTEND_URL="https://social.example.com" -
Restart Rustiz. Check readiness:
rustiz providers --status | jq '.[] | select(.identifier=="tumblr")'It should report
"configured": trueand no missing variables.
Products and APIs
Tumblr has no “products” to enable in the app console. The app is a single OAuth 2.0 client. The scopes are requested at authorization time, not stored on the app.
Exact OAuth scopes
Tumblr’s scopes() returns:
#![allow(unused)]
fn main() {
vec!["write", "offline_access"]
}
The authorization URL sends scope=write+offline_access (space-delimited in
the URL). authenticate() calls check_scopes(&["write", "offline_access"], token["scope"]); the token response must contain both tokens, split on a
comma if present, otherwise on spaces. A response with only write fails with
not_enough_scopes.
writeis required to create posts.offline_accessis required to receive a refresh token. Without it, the 42-minute access token cannot be renewed and the channel stops working.
Redirect URI details
The exact URI constructed by the Rust code is:
{FRONTEND_URL (trailing slash trimmed)}/integrations/social/tumblr
Examples:
| Deployment | Redirect URI to register |
|---|---|
Production https://social.example.com | https://social.example.com/integrations/social/tumblr |
Local default FRONTEND_URL | http://localhost:5000/integrations/social/tumblr |
| Postiz docs local example | http://localhost:4200/integrations/social/tumblr |
| Postiz docs Docker example | http://localhost:4007/integrations/social/tumblr |
The last two are only examples of different FRONTEND_URL values; the source
of truth is always your FRONTEND_URL. The same URI is sent in the
authorization request and in the token exchange, so it must match the app’s
callback URL exactly. There is no redirectmeto.com (or other relay) prefix:
Rustiz builds the URI directly from FRONTEND_URL. If FRONTEND_URL is
http://localhost:5000, the redirect is HTTP; a production https://...
frontend produces an HTTPS redirect and must be registered as such.
Sandbox / testing mode
Tumblr has no sandbox environment and no separate test app mode.
- The OAuth app is usable immediately after registration; there is no review queue.
- Use a secondary Tumblr account or a secondary blog for tests. A post is
published immediately (
state: "published"); there is no draft or queue setting in this provider. - The
pagesfunction lets you choose a non-primary blog, so a test blog can be connected without touching the main blog. - Rate-limit removal is a manual request, not an app review: on
https://www.tumblr.com/oauth/apps, use Request rate limit removal
under the app (it opens an email to
api_ratelimit@tumblr.com). Default limits are 1,000 API calls per hour and 5,000 per day per consumer key.
Production app review
There is no formal app review. The app can authorize users as soon as it is registered. Production concerns are the default rate limits, Tumblr’s posting limits (for example 250 new posts per day, 250 images, 20 videos and 60 minutes of video per day), and the app being suspended for abuse. All are platform policy, not an approval step in Rustiz.
Connecting a Channel (CLI & MCP)
CLI
# Opens the Tumblr authorization page in the default browser.
rustiz channels connect tumblr --open
# Without --open: the URL is printed to stderr and the command waits.
rustiz channels connect tumblr
# If the server is remote (--url), the CLI cannot see the finished login:
# open the URL yourself, then poll.
rustiz channels connect-status <connectionId>
In local/in-process mode the CLI serves the callback on the port of
FRONTEND_URL (if it is this machine) and waits up to --timeout seconds
(default 300) for the new channel to appear. On success it prints a JSON
document with connected: true and the channel.
The OAuth result initially represents the Tumblr user, not a blog. The
channel is marked in_between_steps, so it still needs the blog selection
below before it can publish.
The Rustiz CLI has no
channel setup/triggercommand. A Tumblr channel started withrustiz channels connectcan finish OAuth, but it remainsin_between_steps; complete it with the MCPtriggerTool+channelSetupToolcalls below (or an MCP/API client that exposes them). Until then, publishing is blocked withChannel setup is not finished.
MCP
Start the OAuth flow:
{ "provider": "tumblr" }
The tool returns one of:
-
a URL elicitation to clients that support it, or
-
a normal result:
{ "connected": false, "pending": true, "connectionId": "abc123", "url": "https://www.tumblr.com/oauth2/authorize?...", "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList." }
Give the URL to a human, then poll:
{ "connectionId": "abc123" }
After the login, call integrationList and take the Tumblr channel id.
Finish the blog selection (is_between_steps)
-
List the blogs with
triggerTooland thepagesfunction:{ "integrationId": "int_...", "methodName": "pages" }Each result looks like:
{ "id": "alice", "name": "Alice's Blog", "username": "https://alice.tumblr.com/", "followers": 12, "primary": true, "picture": { "data": { "url": "https://api.tumblr.com/v2/blog/alice.tumblr.com/avatar/128" } } }idis the blog name (the*.tumblr.comsubdomain), not the numeric id and not the display title.nameis the display title. -
Choose one and call
channelSetupTool:{ "integrationId": "int_...", "data": { "id": "alice" } }fetch_page_information()looks up thatidinpages(), thenfinish_integration_setup()updates the channel:internalId= blog name;name= blog name (not the display title);picture= avatar URL;profile= blog URL;inBetweenSteps = 0.
-
Verify with
integrationList. Until this step is done,validatePostToolreportsChannel setup is not finishedand scheduled posts fail.
A successful setup returns:
{ "success": true, "name": "alice" }
Provider functions (triggerTool)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
pages | none | Array of {id, name, username, followers, primary, picture.data.url} | Calls GET /user/info with the channel token and maps every blog. id is the blog name; username is the blog URL; picture.data.url is the avatar endpoint. |
pages has an empty input_schema / dataSchema. It is the only provider
function; there is no comment/thread function.
Post Settings & Media Validation
settings_schema()
CLI note: the simple
rustiz posts create --channel <id> --content "..."form cannot pass provider settings. Userustiz posts create --json post.jsonwith aCreatePostDto(type,date,posts[].settings, …) when any of these fields are required.
Every field is optional; an empty settings object is valid.
| Field | Type | Required | Validation / behavior |
|---|---|---|---|
title | string | No | minLength: 1, maxLength: 4096. Published as the first content block, {"type":"text","subtype":"heading1","text":...}. |
link | string | No | Optional URL. Published as a {"type":"link","url":...} block after the text. A value without http gets https:// prepended. Empty is ignored. |
sourceUrl | string | No | Optional attribution URL, sent as source_url. A value without http gets https:// prepended. Empty is ignored. |
tags | string | No | maxLength: 4096. A comma-separated string, not an array. Sent as tags unchanged; Rustiz does not split, trim or validate the individual tags. |
Example object form:
{
"title": "Behind the process",
"link": "https://example.com/article",
"sourceUrl": "https://example.com/original",
"tags": "design, typography, process"
}
MCP settings array:
{
"settings": [
{ "key": "title", "value": "Behind the process" },
{ "key": "tags", "value": "design, typography, process" }
]
}
The engine adds "__type": "tumblr" automatically if missing.
validate_settings() rules
- If
settingsis not an object, validation returnsOk(())(no fields to check). - For each of
title,link,sourceUrl,tags:- a missing or
nullvalue is skipped; - any present value must be a string.
- a missing or
titlemust be between 1 and 4096 characters inclusive.tagsmust be at most 4096 characters.link/sourceUrl, when non-empty, must match the optional-URL rule:^((https?://)|www\.)followed by a host label, a dot and at least two non-space characters. Empty strings are allowed and ignored.
Content blocks and publish behavior
create_content_blocks() builds the NPF content array in this order:
titleas atext/heading1block (if non-empty).- The message split on runs of two or more newlines, each part trimmed and
chunked to 4096 Unicode code points; each chunk is a
textblock. linkas alinkblock (if non-empty).- One block per media item:
.mp4→{"type":"video","provider":"tumblr","media":{"type":...,"identifier":"media-N","width","height"}};- everything else →
{"type":"image","media":[{...}],"alt_text"?}.
If the resulting array is empty, a single empty text block is sent so Tumblr accepts the post.
The payload is:
{
"content": [ ...blocks... ],
"state": "published",
"tags": "design, process",
"source_url": "https://example.com/original"
}
tags and source_url are omitted when empty.
- If there is no media, the payload is sent as JSON to
POST /blog/{blog}/posts. - If there is media, the same JSON is sent as the
jsonmultipart part, plus onemedia-Npart per attachment. Files are streamed from the media store/URL; each retry re-opens the streams. The response must be a JSON object with aresponseobject, otherwise the provider raisesTumblr did not return a valid post response. - The post id is read from
response.id_string, thenresponse.post_id, thenresponse.id. - The release URL is
{integration.profile}/post/{postId}with a trailing slash removed from the profile. After blog setup,profileis the blog URL (for examplehttps://alice.tumblr.com). If it is missing, the fallback is{TUMBLR_WEB_BASE_URL}/{id}/post/{postId}, which is not the blog URL — this is why the setup step matters.
Media requirements
check_validity()counts the first post’s media:- more than 30 images →
Tumblr supports up to 30 images in one post.; - more than 1 video →
Tumblr supports one uploaded video in one post..
- more than 30 images →
- “Video” is detected only by a case-insensitive
.mp4substring in the path..movand.webmare treated as images even thoughmime_type()knows their MIME types;image_dimensions()will normally fail on them. - Images require readable dimensions. The provider calls
self.ctx.media.image_dimensions(path)and fails the post if it cannot determine width/height. - Video dimensions come from
media.thumbnailwhen present; otherwise they default to 540×405. A thumbnail that cannot be read also falls back to the default. - MIME type is inferred from the path extension: png, jpg/jpeg, gif, webp,
bmp, svg, mp4, mov, webm; unknown extensions become
application/octet-stream. - Tumblr’s platform limits apply in addition: up to 30 images or one video, daily media/video upload limits, and the blog queue limit. The provider maps Tumblr error codes 8004 (daily media), 8011 (daily video), 8022 (queue) and related codes to specific messages.
Plugs
No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the
trait defaults and return ProviderError::Unsupported.
Implementation Notes & Gotchas
- Two-step connect is mandatory. OAuth alone leaves
in_between_steps = true. You must callpagesand thenchannelSetupToolwith the blog name. A channel in this state is listed but cannot publish. - OAuth
idis the Tumblr user, not the blog.authenticate()setsid/name/usernamefrom the Tumblr user’sname. OnlychannelSetupToolchangesinternalIdto the selected blog name andprofileto the blog URL. Do not use the OAuth result’s id as a blog name. - The blog selector’s
idis the subdomain.pages()returnsid: blog.name;nameis the display title.channelSetupToolexpects{"id":"alice"}, not{"id":"Alice's Blog"}and not a numeric id. - Rate limit is per consumer key. Tumblr’s default is 1,000 calls/hour
and 5,000 calls/day for the app, shared by all connected blogs. A single
blog’s posting limits (250 posts/day, 250 images, 20 videos, 60 minutes of
video) are separate.
handle_errors()maps 429 and 503 to retryable errors; the HTTP layer retries up to three times with the configured delay. - Access tokens are short-lived. Tumblr’s token response uses
expires_in: 2520(42 minutes).offline_accessis what makes the refresh token available; without it the channel dies after the first token expires.refresh_token()sendsgrant_type=refresh_tokenwith the app credentials and noredirect_uri(correct for a refresh), and keeps the old refresh token when Tumblr omits a new one. - Refresh identity does not move the channel.
refresh_token()derives the user identity again, but the engine’s refresh path only updates the token fields, notinternalIdorprofile, so the selected blog remains the destination. Do not “fix” the channel by reconnecting the wrong blog. - 401/
invalid_grant/invalid_tokenmeans refresh.handle_errors()returns a refresh action for those, plusPlease re-authenticate your Tumblr account.A failed refresh marks the channelrefresh_needed. - Posting is always public.
stateis hard-coded to"published". Tumblr’s queue/draft states are not exposed; Rustiz’s scheduler controls when the publish call happens. - Media is streamed, not buffered. The multipart path re-opens the source
stream for every retry. This is deliberate; do not replace it with a
Vec<u8>of the whole file. - A non-JSON 200 can look like success to axios but not here. The
multipart code explicitly requires
response.responseto be an object. A proxy/WAF HTML page becomesTumblr did not return a valid post responseinstead of an empty post id. titleis a heading block, not post metadata. It counts against the 4096-character block limit. Very long titles are rejected by validation, not chunked.tagsis a string. Sending an array (as you would for Dev.to or Hashnode) fails validation withtags must be a string. The string is not normalized; Tumblr expects comma-separated tags.- Only
.mp4is treated as video..mov/.webmare misclassified as images and usually fail atimage_dimensions(). Convert videos to MP4. check_validitydoes not validate file types or sizes. It only counts images/videos. A PDF or an oversized image passes the provider check and is rejected by Tumblr.- Tumblr’s post URL fallback is wrong. If
integration.profileis empty (for example a channel created outside the normal setup flow), the release URL becomeshttps://www.tumblr.com/{id}/post/{postId}, which is not the blog post URL. Always finish the blog setup. - No comments, no analytics.
supports_comments,supports_analyticsandsupports_post_analyticsare all false. Extra post parts are ignored by the single-post provider.
References
- Tumblr API v2 documentation (OAuth 2, NPF, blog posts): https://www.tumblr.com/docs/en/api/v2
- Tumblr app registration: https://www.tumblr.com/oauth/apps
- Tumblr developer resources and rate limits: https://help.tumblr.com/knowledge-base/developer-resources
- Postiz Tumblr self-host guide (scopes and redirect): https://docs.postiz.com/self-host/providers/tumblr
- Postiz provider settings: https://docs.postiz.com/public-api/providers/tumblr
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview