Dribbble (dribbble)
Dribbble is an OAuth 2.0 provider for publishing a single shot image. The API
is a single-shot multipart upload: one image is posted to
POST https://api.dribbble.com/v2/shots, and the new shot id is read from the
Location response header. Dribbble creates shots asynchronously, but the
Rustiz port marks the post Completed as soon as the API accepts the upload.
- Rust source:
crates/rustiz-providers/src/dribbble.rs - Upstream port:
dribbble.provider.ts - Editor: Normal (plain text)
- Connection mode:
oauth
Read the gotchas before using this provider. The refresh path is a faithful port of an upstream copy/paste bug that calls Pinterest with Pinterest credentials and a Pinterest redirect URI. The
teamsetting is validated but never sent. See Implementation Notes & Gotchas.
Overview
| Property | Value |
|---|---|
| Identifier | dribbble |
| Display name | Dribbble |
| Connection mode | oauth (authorization code; no refresh token in practice) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | No. supports_comments = false; only the first PostDetails is published |
| Pending / async publishing | No. supports_pending = false; Dribbble’s 202 + Location is treated as completed immediately |
| Analytics | supports_analytics = true, but analytics() returns an empty array. The flag advertises the capability; there is no actual data. |
| Post analytics | supports_post_analytics = true, but post_analytics() returns an empty array. |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 3 |
Character limit (max_length) | 40000 characters for the shot description/body |
| API | Dribbble API v2 |
Server Environment Variables
required_env() returns exactly two variables:
#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
&["DRIBBBLE_CLIENT_ID", "DRIBBBLE_CLIENT_SECRET"]
}
}
required_env_alternatives() is not overridden. provider_status will refuse
to generate a login URL if either is empty.
| Variable | Required | Default | Purpose |
|---|---|---|---|
DRIBBBLE_CLIENT_ID | Yes | none | OAuth application client id. |
DRIBBBLE_CLIENT_SECRET | Yes | none | OAuth application client secret. |
FRONTEND_URL | Yes, in practice | http://localhost:5000 | Builds the OAuth redirect URI. Must be the public origin Dribbble can reach; a trailing slash is stripped. |
DRIBBBLE_OAUTH_BASE_URL | No | https://dribbble.com | Overrides the authorize/token origin. The paths appended are /oauth/authorize and /oauth/token. |
DRIBBBLE_BASE_URL | No | https://api.dribbble.com | Overrides the API origin. Paths appended are /v2/user and /v2/shots. |
PINTEREST_BASE_URL | No | https://api-sandbox.pinterest.com | Only used by the broken refresh path. It does not affect normal publishing. |
PINTEREST_CLIENT_ID | No | none | Read only by the broken refresh path; not declared in required_env(). |
PINTEREST_CLIENT_SECRET | No | none | Read only by the broken refresh path; not declared in required_env(). |
required_env() deliberately omits PINTEREST_*, even though
refresh_token() reads them. That means rustiz providers --status says
Dribbble is configured while refresh support is silently incomplete.
Step-by-Step Developer App Setup
-
Sign in to Dribbble and open https://dribbble.com/account/applications/new.
-
Fill in:
-
Name — for example
Rustiz. -
Description — required by the form.
-
Website — your product or instance URL.
-
Callback URL / Redirect URI — enter exactly:
{FRONTEND_URL}/integrations/social/dribbbleFor example
https://social.example.com/integrations/social/dribbble. Do not add a trailing slash.
-
-
Save the application and copy the Client ID and Client Secret. The Postiz Dribbble guide says both are 64 characters long.
-
Set them on the server:
DRIBBBLE_CLIENT_ID="your-client-id" DRIBBBLE_CLIENT_SECRET="your-client-secret" FRONTEND_URL="https://social.example.com" -
Restart Rustiz and check:
rustiz providers --status | jq '.[] | select(.identifier=="dribbble")'configuredshould be true with no missing variables.
Spelling warning. The Postiz documentation page for Dribbble currently shows
DRIBBLE_CLIENT_ID/DRIBBLE_CLIENT_SECRET(twobs, not three) and a redirect path/integrations/social/dribble(one fewerb). Those are typos. This Rust port usesDRIBBBLE_CLIENT_ID/DRIBBBLE_CLIENT_SECRETand the correct/integrations/social/dribbblepath. Register the URI the Rust code actually builds, not the one on that docs page.
Products and APIs
Dribbble has no products or API-enablement console. Registering an OAuth application is enough. The scopes are requested in the authorization URL.
Exact OAuth scopes
scopes() returns:
#![allow(unused)]
fn main() {
vec!["public", "upload"]
}
The authorization URL sends scope=public+upload (a space-separated list,
encoded as +). authenticate() calls
check_scopes(&["public", "upload"], token["scope"]); the token response must
contain both scopes. upload is the one that permits POST /v2/shots;
public is read-only public information.
Redirect URI details
The exact URI is:
{FRONTEND_URL (trailing slash trimmed)}/integrations/social/dribbble
Examples:
| Deployment | Redirect URI to register |
|---|---|
Production https://social.example.com | https://social.example.com/integrations/social/dribbble |
Local default FRONTEND_URL | http://localhost:5000/integrations/social/dribbble |
| A local frontend on port 4200 | http://localhost:4200/integrations/social/dribbble |
| A Docker frontend on port 5000 | http://localhost:5000/integrations/social/dribbble |
Dribbble’s own OAuth docs say the redirect host and port must exactly match
the registered callback; if redirect_uri is omitted, the registered
callback is used. Rustiz always sends it, both at authorize time (URL-encoded)
and at token exchange (unencoded; see gotchas). There is no
redirectmeto.com relay in this provider: the value is built directly from
FRONTEND_URL, so an HTTP local frontend registers an HTTP URI and an HTTPS
production frontend registers an HTTPS URI.
Sandbox / testing mode
Dribbble has no sandbox environment and no separate test app mode.
- The app is usable immediately after registration; there is no documented review/approval queue.
- Use a secondary Dribbble account for tests. A shot created through the API is public; there is no draft status in the create-shot API.
scheduled_forexists in Dribbble’s API but requires a Pro account, team membership or a team; Rustiz does not expose it. The scheduler controls when Rustiz calls the API.- Rate limits are 60 requests per minute and 1,440 per day per
authenticated user. Watch
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset; a 429 is returned when exceeded. - The application can be suspended for abuse, spam or API misuse. That is a platform enforcement action, not a pre-publication review.
Production app review
No separate app-review step is documented in the OAuth flow: registration is self-serve and the app can authorize users immediately. Dribbble’s API terms reserve the right to refuse API access at any time and to throttle or block a service, and an app can be suspended for abuse or API misuse. For a public or commercial integration, treat access as discretionary and read the API terms before launch.
Connecting a Channel (CLI & MCP)
CLI
# Opens the Dribbble authorization page in the default browser.
rustiz channels connect dribbble --open
# Without --open: the URL is printed to stderr and the command waits.
rustiz channels connect dribbble
# Remote server: 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 for the new channel. On
success it prints the channel JSON.
MCP
Start the OAuth flow:
{ "provider": "dribbble" }
The tool returns either a URL elicitation (for clients that support it) or:
{
"connected": false,
"pending": true,
"connectionId": "abc123",
"url": "https://dribbble.com/oauth/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" }
There is no follow-up step: is_between_steps is false. Call
integrationList for the channel id.
Provider functions (triggerTool)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
teams | none | [{"id": <number>, "name": "..."}] | Calls GET /v2/user and maps teams. If the user has no teams, returns []. |
MCP:
{ "integrationId": "int_...", "methodName": "teams" }
The function has an empty input_schema / dataSchema.
The
teamsresult cannot currently be used for posting. Theteamsetting is ignored bypost(), and Dribbble requires a numericteam_idfield in the shot upload, which the provider never sends. Treatteamsas informational only.
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.
| Field | Type | Required | Validation / behavior |
|---|---|---|---|
title | string | Yes | minLength: 1; must be a non-empty string. No maximum is enforced by Rustiz. This is Dribbble’s required shot title. |
team | string | No | Described as a team URL/id. validate_settings requires a valid URL when the value is present and non-null. It is never read by post(). An empty string fails validation; omit the field or use null to post on the personal profile. |
There is no description, tags, low_profile, rebound_source_id or
scheduled_for setting. The post body (PostDetails.message) becomes the
shot description.
Example object form:
{
"title": "Dashboard UI — dark mode",
"team": null
}
MCP settings array:
{
"settings": [
{ "key": "title", "value": "Dashboard UI — dark mode" }
]
}
The engine adds "__type": "dribbble" automatically if missing.
validate_settings() rules
titlemust exist and be a string; it must not be empty.team, if present and notnull, must be a string and must parse as a URL.- An empty string is not a URL and fails with
team must be a URL. This matters becausedocs.postiz.comexamples use"team": "". - Use
nullor omitteamfor a personal shot.
- An empty string is not a URL and fails with
- There is no check that the team URL belongs to the authenticated user,
and no conversion from a team URL to Dribbble’s numeric
team_id.
check_validity() rules
check_validity() is called before scheduling/publishing with each part’s
media. It looks only at the first post part:
- If any item’s path contains
"mp4"(case-sensitive), returnDoes not support mp4 files. - Otherwise, the part must have exactly one media item. Zero or two or
more returns
Requires one item. - It reads the image dimensions and accepts only:
- 400×300, or
- 800×600.
Anything else returns
Invalid image size. Requires 400x300 or 800x600 px images.
Because the MP4 check runs before the count check, a two-item list containing
an MP4 returns the MP4 message, not Requires one item.
The validation does not check:
- file size (Dribbble’s limit is 8 MB);
- actual file type (Dribbble accepts GIF, JPG or PNG);
- file extension;
- whether the image is animated or has the right color profile.
A 400×300 WebP or a 400×300 20 MB PNG passes Rustiz validation and is then rejected by Dribbble’s API.
Media and publish behavior
- Exactly one media item is used:
first.media.first(). All other media is ignored. - The file is streamed as multipart form data under the field name
image, with the original filename and a MIME type inferred from the extension (jpg/jpeg,png,gif,webp,mp4; unknown → no MIME part). - The form also sends:
titlefromsettings.title;descriptionfromfirst.message.
- It does not send
team_id,tags,low_profile,rebound_source_idorscheduled_for. - Dribbble’s API responds 202 Accepted with a
Locationheader such ashttps://api.dribbble.com/v2/shots/471756. Rustiz treats any 2xx as success, takes the last path segment as the post id, and returnshttps://dribbble.com/shots/{id}. The shot may return 404 at that URL until Dribbble finishes processing, because creation is asynchronous. - If the 2xx response has no
Location, the post id is empty and the release URL becomeshttps://dribbble.com/shots/— a silent bad success. - A 4xx other than 429 is converted to a
bad_bodyerror using the platformmessagefield, orDribbble rejected the shot with status {status}if there is no message. 429 is passed to the generic retry path.
Plugs
No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the
trait defaults and return ProviderError::Unsupported.
Implementation Notes & Gotchas
The refresh path calls Pinterest (upstream bug, faithfully ported)
This is the most important thing to know about dribbble.rs. The module
header says it explicitly:
Faithful port note: upstream
refreshTokencalls the Pinterest sandbox endpoints withPINTEREST_CLIENT_ID/PINTEREST_CLIENT_SECRET. That copy paste bug is kept here.
In refresh_token() (around lines 258–318):
-
It builds Basic auth from
PINTEREST_CLIENT_IDandPINTEREST_CLIENT_SECRET. -
It posts to
{PINTEREST_BASE_URL}/v5/oauth/token, defaulthttps://api-sandbox.pinterest.com/v5/oauth/token. -
The form contains
grant_type=refresh_token, the Dribbble refresh token,scope=public,upload, and aredirect_uriof:{FRONTEND_URL}/integrations/social/pinterestThat is
pinterest, notdribbble— line 283 ofdribbble.rs. -
It then calls
{PINTEREST_BASE_URL}/v5/user_accountwith the returned token and returns aAuthTokenDetailswhoseid,name,usernameandpictureare Pinterest account fields.
Consequences:
- A Dribbble refresh will fail unless Pinterest sandbox credentials are also configured, and it will send the Dribbble refresh token to Pinterest.
- Even if it succeeds, the token is a Pinterest token and the returned identity is a Pinterest user; it cannot be used to post to Dribbble.
PINTEREST_CLIENT_ID/PINTEREST_CLIENT_SECRETare not declared inrequired_env(), so the deployment’s provider status will not warn about them.- Normal Dribbble operation tries to avoid refresh:
authenticate()setsexpires_in: Some(999999999)(about 31.7 years) andrefresh_token: Some(""). Dribbble’s token response does not document a refresh token. A 401 still enters the refresh path, where this bug fires. - Workaround: do not rely on token refresh. If a Dribbble channel is
marked refresh-needed or starts returning 401, reconnect it through
rustiz channels connect dribbblerather than trying to repair the token.
Other gotchas
-
The
teamsetting is dead code. It is in the schema and validated, butpost()never reads it. Dribbble’s create-shot API wants a numericteam_id; theteamstool returns numeric ids, but there is no path from the setting to the request. Team shots cannot be published by this provider. -
"team": ""fails validation. The docs examples use an empty string, butis_url("")is false, so the call fails withteam must be a URL. Omit the key or sendnull. -
The token exchange query is hand-built and unencoded.
authenticate()constructs:POST {DRIBBBLE_OAUTH_BASE_URL}/oauth/token?client_id=...&client_secret=...&code=...&redirect_uri=...The authorization code and redirect URI are not URL-encoded. Dribbble authorization codes are normally URL-safe and the redirect URI has no query string, so this usually works, but a client secret or code containing
&,#or+would break the request. -
expires_inis synthetic. Dribbble’s OAuth token response containsaccess_token,token_typeandscope, but noexpires_in. Rustiz stores999999999seconds so the engine will not try to refresh. This is a deliberate workaround, not data from Dribbble. -
No refresh token is stored.
authenticate()setsrefresh_token: Some(""). Any engine refresh attempt passes an empty string to the Pinterest bug. There is no way to recover a Dribbble token through this provider. -
analyticsflags are misleading.info.supports_analyticsandinfo.supports_post_analyticsaretrue, so generic callers may ask for analytics, but both methods returnOk(vec![]). Dribbble does not expose detailed shot analytics through this API. -
Creation is asynchronous. Dribbble returns 202 +
Location; the shot may 404 for a short time while it is processed. Rustiz marks the postCompletedimmediately. A later analytics/post lookup at the returnedhttps://dribbble.com/shots/{id}may briefly fail. -
Missing
Locationis a silent empty success. The code does not treat a 2xx withoutLocationas an error; it returns an empty post id andhttps://dribbble.com/shots/. Check the response if a Dribbble post appears successful with no URL. -
Rate limits are per authenticated user. 60 requests/minute and 1,440/day.
max_concurrent_job = 3keeps concurrency low, but retries and multiple channels on one account share the limit. 429 is retried by the generic HTTP layer up to three times; there is no provider-specifichandle_errors()mapping. -
Validation is dimensions-only. See the media section: size, MIME and extension are unchecked. Use 800×600 PNG/JPG under 8 MB for the most reliable result.
-
Description is plain text. The editor is
normal, so HTML is stripped beforemessagereaches Dribbble. Tags are not sent; put hashtags in the description if desired. -
The release URL is constructed, not read from Dribbble. It is always
https://dribbble.com/shots/{id}; Dribbble’shtml_urlfield is not used.
References
- Dribbble OAuth documentation (authorize/token, redirect rules, scopes, errors): https://developer.dribbble.com/v2/oauth/
- Dribbble create-shot documentation (400×300 or 800×600, 8 MB, GIF/JPG/PNG, video unsupported, 202 + Location, team_id, rate limits): https://developer.dribbble.com/v2/shots/
- Dribbble API overview and rate limits: https://developer.dribbble.com/v2/
- Dribbble application registration: https://dribbble.com/account/applications/new
- Postiz Dribbble self-host guide (contains the
DRIBBLE_*/dribbletypos — use the Rust names instead): https://docs.postiz.com/self-host/providers/dribbble - Postiz provider settings (note the
"team": ""example, which Rustiz rejects): https://docs.postiz.com/public-api/providers/dribbble - Postiz posting rules: https://docs.postiz.com/general/platforms/overview