cloudinary for Dart

cloudinary

Every Cloudinary endpoint, in one pure-Dart package that never asks your app to hold a secret it shouldn't.

pub package CI pub likes pub points licence

93 API methods across Upload, Admin and Search  ·  388 tests  ·  2 runtime dependencies  ·  0 Flutter dependencies

Every signature, auth token and delivery URL is pinned by golden vectors derived from Cloudinary's own algorithm, so correctness here is measured rather than asserted. There is no benchmark: a client's speed is the network's. The method and test counts are checked by test/readme_test.dart.

Live demo  ·  Quick start  ·  Uploading  ·  Delivery URLs  ·  Admin  ·  Search  ·  Is this for you  ·  Migrating from 1.x  ·  API reference

Table of contents

Overview

This package talks to Cloudinary's Upload, Admin and Search APIs and builds signed delivery URLs. It is written in pure Dart with no Flutter dependency, so the same code runs in a Flutter app, a Dart backend, a CLI, and on the web.

Responses come back as typed models, failures throw typed exceptions, and every model also exposes the raw decoded map, so a field Cloudinary added last week is reachable today rather than after a release here.

Demo

Try every feature in the live web demo: a delivery URL playground on Cloudinary's public demo cloud, a real unsigned upload to your own cloud, signatures, signed URLs, auth tokens and webhook verification computed with a throwaway secret, a search query builder, a tour of the Admin API, CLOUDINARY_URL parsing and the exception family. It is the example app built for the web; the same app runs on Android, iOS, macOS, Windows and Linux.

Quick start

Prerequisites

  • Dart SDK ^3.13.0 (Flutter 3.47 or newer bundles a compatible SDK).
  • A Cloudinary account. Your cloud name, API key and API secret are on the dashboard.
  • For client-side uploads, an unsigned upload preset, so the app needs no secret.

Install

dependencies:
  cloudinary: ^2.0.0

Then dart pub get or flutter pub get, and import it:

import 'package:cloudinary/cloudinary.dart';

Create a client

On a server, where the secret is safe:

final cloudinary = Cloudinary.signed(
  cloudName: 'your-cloud',
  apiKey: 'your-key',
  apiSecret: 'your-secret',
);

In a Flutter or web app, where it is not:

final cloudinary = Cloudinary.unsigned(cloudName: 'your-cloud');

You can also read the standard CLOUDINARY_URL environment variable:

final cloudinary = Cloudinary.fromEnvironment();

Call cloudinary.close() when you are done, unless you passed your own http.Client, in which case closing it is yours to do.

Uploading

final result = await cloudinary.upload.upload(
  file: CloudinaryFileSource.path('/path/to/photo.jpg'),
  folder: 'trips/2026',
  tags: ['holiday'],
  onProgress: (sent, total) => print('$sent / $total'),
);

print(result.secureUrl);

From a client app with an unsigned preset, and no credentials anywhere:

final result = await cloudinary.upload.unsignedUpload(
  file: CloudinaryFileSource.bytes(bytes, filename: 'photo.jpg'),
  uploadPreset: 'my_unsigned_preset',
);

A file can come from a path, from bytes, or from a URL Cloudinary fetches itself:

CloudinaryFileSource.path('/path/to/photo.jpg');   // not available on the web
CloudinaryFileSource.bytes(bytes, filename: 'a.png');
CloudinaryFileSource.url('https://example.com/a.png');

The rest of the Upload API is there too: explicit, rename, destroy, destroyByAssetId, addTag, removeTag, replaceTag, removeAllTags, addContext, removeAllContext, updateMetadata, explode, multi, generateSprite, text, createArchive, createZip and deleteByToken.

Delivery URLs

URL building is pure and synchronous, so it is safe to call inside a widget build.

final url = cloudinary.url.image('trips/2026/photo.jpg')
    .transform(Transformation()
      ..width(600)
      ..height(400)
      ..crop(CropMode.fill)
      ..gravity(Gravity.auto)
      ..quality(Quality.auto)
      ..format(DeliveryFormat.auto))
    .build();

Chain several transformations when one stage feeds the next:

final url = cloudinary.url.image('photo.jpg')
    .transformChain(TransformationChain([
      Transformation()..width(600)..crop(CropMode.fill),
      Transformation()..effect(Effect.sepia),
    ]))
    .build();

Anything without a typed setter goes through raw, so you are never blocked:

Transformation()..raw('e_custom:42');

Sign a URL so nobody can edit the transformation, or attach a time-limited token:

cloudinary.url.image('private.jpg').signed().build();

cloudinary.url.image('private.jpg').authToken(
  AuthToken(key: 'your-token-key', acl: '/image/*', duration: 3600),
).build();

Private CDN distributions, CNAMEs, CDN subdomain sharding, SEO suffixes, short URLs and version pinning are all configured once through UrlConfig.

Admin API

Grouped the way Cloudinary's own documentation is, so things are where you expect:

await cloudinary.admin.account.ping();
await cloudinary.admin.account.usage();

final page = await cloudinary.admin.resources.list(maxResults: 50);
final more = await cloudinary.admin.resources.list(nextCursor: page.nextCursor);

await cloudinary.admin.folders.create('trips/2026');
await cloudinary.admin.tags.list(prefix: 'hol');
await cloudinary.admin.transformations.create(name: 'thumb', transformation: 'w_150,h_150,c_fill');
await cloudinary.admin.uploadPresets.create(name: 'mobile', unsigned: true);
await cloudinary.admin.metadataFields.create(
  externalId: 'photographer',
  label: 'Photographer',
  type: MetadataFieldType.string,
);

The ten groups are account, resources, folders, tags, transformations, uploadPresets, uploadMappings, streamingProfiles, metadataFields and metadataRules.

resources.deleteAll requires confirm: true. Cloudinary has no undo for it.

Search API

final results = await cloudinary.search
    .expression('resource_type:image AND tags=holiday')
    .sortBy('created_at', SortDirection.desc)
    .aggregate('format')
    .maxResults(50)
    .execute();

for (final asset in results.resources) {
  print('${asset.publicId} ${asset.bytes}');
}

For a query you run repeatedly from clients, build a signed, cacheable search URL instead:

final url = cloudinary.search.expression('tags=holiday').toUrl(ttl: 300);

Keeping your API secret out of your app

An API secret shipped in a mobile or web build is readable by anyone who downloads it. This package makes that hard to do by accident:

  • Cloudinary.signed throws on a web runtime unless you pass allowSecretOnWeb: true.
  • Guards are thrown exceptions, never assert, because Dart strips asserts from release builds. A guard that vanishes in production is not a guard.
  • The API secret never appears in a URL, and toString() redacts it.

When a client genuinely needs a signed operation, sign it on your server and hand the signature back:

class MySigner implements SignatureProvider {
  @override
  Future<RemoteSignature> sign(Map<String, dynamic> params) async {
    final res = await myBackend.post('/cloudinary/sign', params);
    return RemoteSignature(
      signature: res['signature'],
      timestamp: res['timestamp'],
      apiKey: res['api_key'],
    );
  }
}

final cloudinary = Cloudinary.unsigned(
  cloudName: 'your-cloud',
  signatureProvider: MySigner(),
);

Handling errors

Failures throw. The hierarchy is sealed, so a switch over it is exhaustive and the analyzer tells you when a new case appears.

try {
  await cloudinary.upload.upload(file: source);
} on CloudinaryRateLimitException catch (e) {
  print('Rate limited, resets at ${e.resetAt}');
} on CloudinaryAuthException {
  print('Check your credentials');
} on CloudinaryApiException catch (e) {
  print('Cloudinary said ${e.statusCode}: ${e.message}');
} on CloudinaryTransportException catch (e) {
  print('Never reached Cloudinary: ${e.cause}');
}

Responses with status 420, 429, 502, 503 or 504 are retried automatically, honouring Retry-After and Cloudinary's X-FeatureRateLimit-Reset. A 500 is never retried, because the request may have partly succeeded, and for the same reason a POST or DELETE retries only on 420 and 429, where the server states it did not act. Configure or disable it with RetryPolicy.

Verifying webhooks

final ok = verifyNotificationSignature(
  body: rawRequestBody,          // the raw bytes as received, not re-encoded
  timestamp: int.parse(headers['x-cld-timestamp']!),
  signature: headers['x-cld-signature']!,
  apiSecret: 'your-secret',
);

The comparison is constant-time and payloads older than two hours are rejected, which limits replay.

Before and after

Version 1 returned a response object whether or not the call worked, so a failure looked like a success until you checked the right getter:

// v1
final response = await cloudinary.upload(file: path);
if (response.isSuccessful) {
  print(response.secureUrl);
} else {
  print(response.error);   // easy to forget, and silent when you do
}

Version 2 throws, and returns a typed model:

// v2
final result = await cloudinary.upload.upload(
  file: CloudinaryFileSource.path(path),
);
print(result.secureUrl);

MIGRATION.md maps every v1 call to its v2 equivalent.

Is this for you

Use this package if you want one dependency that covers uploading, administration, search and delivery URLs, with typed errors and no Flutter dependency so your backend and your app can share it.

It fits particularly well if you are uploading from a client app, because the signature provider and the web guard are built for exactly that.

Skip it if you only need to build delivery URLs and never call an API. Cloudinary's own cloudinary_url_gen is purpose-built for that and has a richer typed transformation DSL. This package covers the common transformation parameters and gives you raw for the rest, which is the right trade when URL building is not the main thing you are doing.

Compared to

cloudinary_url_gen is Cloudinary's official URL builder. Its transformation DSL is more expressive than this one: a named constructor per effect, per gravity mode, per qualifier. It does not call the Upload, Admin or Search APIs.

cloudinary_api is Cloudinary's official API client, and pairs with cloudinary_url_gen. If you prefer first-party packages and are happy taking two of them, that combination is a reasonable choice.

cloudinary_flutter adds Flutter widgets on top of the official packages. This package has no widgets and no Flutter dependency, which is what lets it run on a server.

This package is one dependency covering all three APIs plus URL building, with a sealed error hierarchy, a raw-map escape hatch on every model, and client-side secret handling as a first-class concern.

FAQ

Does it work on the web? Yes. The package compiles to JavaScript, and that is verified rather than assumed. The one exception is CloudinaryFileSource.path, which needs a filesystem; use .bytes there and you get a clear exception rather than a mystery if you forget.

Why does Cloudinary.signed throw in my Flutter web build? Because an API secret in a browser bundle is readable by anyone who opens devtools. Use Cloudinary.unsigned with an upload preset, or a SignatureProvider. If you are certain the code never reaches a browser, pass allowSecretOnWeb: true.

Can I use my own HTTP client? Yes, pass any http.Client to the constructor. That is also how the test suite runs with no network. The package does not re-export package:http, so its types are not frozen into this API.

Cloudinary added a response field. Do I have to wait for a release? No. Every model exposes raw, the complete decoded response, so the new field is available immediately as result.raw['new_field'].

What do the numbers in the header mean? They are the things this package controls and can prove: how much of Cloudinary's API is covered, and how much of that is pinned by tests. Request time belongs to Cloudinary and the network, so coverage and correctness are what gets measured here.

Scope

This package covers the Upload, Admin and Search APIs in full, plus delivery URL construction. The Provisioning API (sub-accounts, users, user groups and access keys) and the v2 Analysis API are not covered.

About Cloudinary

Cloudinary is a media API for websites and mobile apps: it stores, transforms, optimises and delivers images and video through multiple CDNs.

Migrating from 1.x

2.0 is a rewrite: failures throw typed exceptions instead of returning an error string, package:http replaces dio, the API is grouped under cloudinary.upload, admin, search and url, and CloudinaryFileSource replaces file and fileBytes. MIGRATION.md maps every 1.x call to its 2.x equivalent.

Contributing

Fork the repository, make your changes and open a pull request. Please read CONTRIBUTING.md first, and note that every PR must bump the version in pubspec.yaml and add a matching CHANGELOG.md entry.

License

MIT. See LICENSE.

Support the project

cloudinary is MIT licensed and free to use, always. If it saves you writing the signing code twice, sponsorship is welcome.


GitHub Sponsors Ko-fi Buy Me a Coffee

Connect

Nikhil Rajput

GitHub LinkedIn X Instagram Telegram Email

Libraries

cloudinary
A complete Cloudinary SDK for Dart and Flutter.