Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

PropertyValue
Identifiertumblr
Display nameTumblr
Connection modeoauth (authorization code, refresh token)
Editornormal
Channel kindsocial
Comments / threadsNo. supports_comments = false; only the first PostDetails is published; extra parts are not supported
Pending / async publishingNo. supports_pending = false; returns PostStatus::Completed
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepstrue — the OAuth login is not enough; the blog must be selected with pages + channelSetupTool
convert_to_jpegfalse
max_concurrent_job3
Character limit (max_length)32768 characters
Text block limit4096 characters per text block (long text is chunked)
APITumblr 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.

VariableRequiredDefaultPurpose
TUMBLR_CLIENT_IDYesnoneTumblr app’s OAuth Consumer Key.
TUMBLR_CLIENT_SECRETYesnoneTumblr app’s OAuth Consumer Secret.
FRONTEND_URLYes, in practicehttp://localhost:5000Builds 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_URLNohttps://api.tumblr.com/v2Overrides 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_URLNohttps://www.tumblr.comOverrides the web origin used to build /oauth2/authorize.
ALLOW_PRIVATE_NETWORKNofalseOnly 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

  1. Sign in to Tumblr and open https://www.tumblr.com/oauth/apps.

  2. Click Register application (or Register a new application).

  3. 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/tumblr
      

      For example https://social.example.com/integrations/social/tumblr. Do not add a trailing slash. The path is generated by format!("{}/integrations/social/tumblr", config.frontend_url()) and frontend_url() strips any trailing slash from FRONTEND_URL.

  4. Save the app. Tumblr shows an OAuth Consumer Key and an OAuth Consumer Secret (the secret is behind Show secret key).

  5. 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"
    
  6. Restart Rustiz. Check readiness:

    rustiz providers --status | jq '.[] | select(.identifier=="tumblr")'
    

    It should report "configured": true and 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.

  • write is required to create posts.
  • offline_access is 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:

DeploymentRedirect URI to register
Production https://social.example.comhttps://social.example.com/integrations/social/tumblr
Local default FRONTEND_URLhttp://localhost:5000/integrations/social/tumblr
Postiz docs local examplehttp://localhost:4200/integrations/social/tumblr
Postiz docs Docker examplehttp://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 pages function 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 / trigger command. A Tumblr channel started with rustiz channels connect can finish OAuth, but it remains in_between_steps; complete it with the MCP triggerTool + channelSetupTool calls below (or an MCP/API client that exposes them). Until then, publishing is blocked with Channel 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)

  1. List the blogs with triggerTool and the pages function:

    {
      "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" } }
    }
    

    id is the blog name (the *.tumblr.com subdomain), not the numeric id and not the display title. name is the display title.

  2. Choose one and call channelSetupTool:

    {
      "integrationId": "int_...",
      "data": { "id": "alice" }
    }
    

    fetch_page_information() looks up that id in pages(), then finish_integration_setup() updates the channel:

    • internalId = blog name;
    • name = blog name (not the display title);
    • picture = avatar URL;
    • profile = blog URL;
    • inBetweenSteps = 0.
  3. Verify with integrationList. Until this step is done, validatePostTool reports Channel setup is not finished and scheduled posts fail.

A successful setup returns:

{ "success": true, "name": "alice" }

Provider functions (triggerTool)

FunctionParametersReturnsNotes
pagesnoneArray 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. Use rustiz posts create --json post.json with a CreatePostDto (type, date, posts[].settings, …) when any of these fields are required.

Every field is optional; an empty settings object is valid.

FieldTypeRequiredValidation / behavior
titlestringNominLength: 1, maxLength: 4096. Published as the first content block, {"type":"text","subtype":"heading1","text":...}.
linkstringNoOptional URL. Published as a {"type":"link","url":...} block after the text. A value without http gets https:// prepended. Empty is ignored.
sourceUrlstringNoOptional attribution URL, sent as source_url. A value without http gets https:// prepended. Empty is ignored.
tagsstringNomaxLength: 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

  1. If settings is not an object, validation returns Ok(()) (no fields to check).
  2. For each of title, link, sourceUrl, tags:
    • a missing or null value is skipped;
    • any present value must be a string.
  3. title must be between 1 and 4096 characters inclusive.
  4. tags must be at most 4096 characters.
  5. 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:

  1. title as a text / heading1 block (if non-empty).
  2. The message split on runs of two or more newlines, each part trimmed and chunked to 4096 Unicode code points; each chunk is a text block.
  3. link as a link block (if non-empty).
  4. 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 json multipart part, plus one media-N part per attachment. Files are streamed from the media store/URL; each retry re-opens the streams. The response must be a JSON object with a response object, otherwise the provider raises Tumblr did not return a valid post response.
  • The post id is read from response.id_string, then response.post_id, then response.id.
  • The release URL is {integration.profile}/post/{postId} with a trailing slash removed from the profile. After blog setup, profile is the blog URL (for example https://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..
  • “Video” is detected only by a case-insensitive .mp4 substring in the path. .mov and .webm are treated as images even though mime_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.thumbnail when 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 call pages and then channelSetupTool with the blog name. A channel in this state is listed but cannot publish.
  • OAuth id is the Tumblr user, not the blog. authenticate() sets id/name/username from the Tumblr user’s name. Only channelSetupTool changes internalId to the selected blog name and profile to the blog URL. Do not use the OAuth result’s id as a blog name.
  • The blog selector’s id is the subdomain. pages() returns id: blog.name; name is the display title. channelSetupTool expects {"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_access is what makes the refresh token available; without it the channel dies after the first token expires. refresh_token() sends grant_type=refresh_token with the app credentials and no redirect_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, not internalId or profile, so the selected blog remains the destination. Do not “fix” the channel by reconnecting the wrong blog.
  • 401/invalid_grant/invalid_token means refresh. handle_errors() returns a refresh action for those, plus Please re-authenticate your Tumblr account. A failed refresh marks the channel refresh_needed.
  • Posting is always public. state is 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.response to be an object. A proxy/WAF HTML page becomes Tumblr did not return a valid post response instead of an empty post id.
  • title is a heading block, not post metadata. It counts against the 4096-character block limit. Very long titles are rejected by validation, not chunked.
  • tags is a string. Sending an array (as you would for Dev.to or Hashnode) fails validation with tags must be a string. The string is not normalized; Tumblr expects comma-separated tags.
  • Only .mp4 is treated as video. .mov/.webm are misclassified as images and usually fail at image_dimensions(). Convert videos to MP4.
  • check_validity does 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.profile is empty (for example a channel created outside the normal setup flow), the release URL becomes https://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_analytics and supports_post_analytics are all false. Extra post parts are ignored by the single-post provider.

References