simple_https_service

(en)Japanese ver is here.
(ja)この解説の日本語版はここにあります。

Overview

This package provides simple HTTPS POST communication for Flutter applications.
It includes timing control to respect server-side rate limits, automatic retry with exponential backoff, and a unified response object that categorizes server responses into easy-to-handle statuses.

Two implementations are provided:

  • HttpsService: Works on both web and native platforms.
  • HttpsServiceForNative: Native-only, with support for self-signed certificates via badCertificateCallback.

Features

  • HTTPS-only POST (http URLs are rejected with an exception)
  • jwt parameter to automatically set the Authorization: Bearer header
  • Unified ServerResponse with six statuses: success, timeout, serverError, otherError, signInRequired, cancelled
  • Response body as JSON, raw bytes, or UTF-8 text
  • Raw binary upload via postBytes (application/octet-stream by default)
  • multipart/form-data upload via postMultipart (text fields + binary files)
  • Caller-driven request cancellation via CancelToken (shareable across multiple requests)
  • TimingManager: Singleton that enforces a minimum interval between requests
  • RetryConfig: Singleton for automatic retry with exponential backoff and configurable jitter

Usage

Basic POST

import 'package:simple_https_service/simple_https_service.dart';

final res = await HttpsService.post(
  'https://api.example.com/data',
  {'key': 'value'},
  EnumPostEncodeType.json,
);

switch (res.resultStatus) {
  case EnumServerResponseStatus.success:
    print(res.resBody);
  case EnumServerResponseStatus.timeout:
    print('timeout: ${res.errorDetail}');
  case EnumServerResponseStatus.serverError:
    print('server error: ${res.errorDetail}');
  case EnumServerResponseStatus.signInRequired:
    print('sign-in required');
  case EnumServerResponseStatus.otherError:
    print('error: ${res.errorDetail}');
  case EnumServerResponseStatus.cancelled:
    print('cancelled');
}

Custom POST (advanced headers)

import 'package:simple_https_service/simple_https_service.dart';

final res = await HttpsService.customPost(
  'https://api.example.com/data',
  '{"key":"value"}',
  {'Content-Type': 'application/json; charset=utf-8'},
  timeout: const Duration(seconds: 10),
);

switch (res.resultStatus) {
  case EnumServerResponseStatus.success:
    print(res.resBody);
  default:
    print('error: ${res.errorDetail}');
}

POST with JWT authentication

import 'package:simple_https_service/simple_https_service.dart';

final res = await HttpsService.post(
  'https://api.example.com/data',
  {'key': 'value'},
  EnumPostEncodeType.json,
  jwt: 'your_access_token',
);

switch (res.resultStatus) {
  case EnumServerResponseStatus.success:
    print(res.resBody);
  case EnumServerResponseStatus.signInRequired:
    print('token expired or invalid');
  default:
    print('error: ${res.errorDetail}');
}

POST raw binary (postBytes)

Use postBytes to send a single binary payload (image, PDF, encrypted blob, etc.) without any encoding. The body is sent as-is.

import 'dart:typed_data';
import 'package:simple_https_service/simple_https_service.dart';

final Uint8List bytes = ...; // e.g. file bytes
final res = await HttpsService.postBytes(
  'https://api.example.com/upload',
  bytes,
  contentType: 'image/png',
  jwt: 'your_access_token',
);

if (res.resultStatus == EnumServerResponseStatus.success) {
  print(res.resBody);
}

POST multipart/form-data (postMultipart)

Use postMultipart to send text fields together with one or more binary files. Only byte input is supported via MultipartFileSpec; streams and file paths are intentionally not accepted so that retries can safely re-send the same payload.

Each MultipartFileSpec can optionally specify a contentType (e.g. "image/png") so the receiving server (or downstream storage such as GCS/S3) records the correct MIME type. When omitted, the multipart default application/octet-stream is used.

import 'dart:typed_data';
import 'package:simple_https_service/simple_https_service.dart';

final Uint8List imageBytes = ...;

final res = await HttpsService.postMultipart(
  'https://api.example.com/upload',
  fields: {'app_version': '1.2.3', 'note': 'avatar'},
  files: [
    MultipartFileSpec(
      field: 'avatar',
      bytes: imageBytes,
      filename: 'avatar.png',
      contentType: 'image/png',
    ),
  ],
  jwt: 'your_access_token',
);

if (res.resultStatus == EnumServerResponseStatus.success) {
  print(res.resBody);
}

Native-only POST with self-signed certificate

Warning
Unconditionally returning true in badCertificateCallback bypasses SSL verification. Avoid using this in production environments (Release mode) or on untrusted networks.

import 'package:flutter/foundation.dart';
import 'package:simple_https_service/simple_https_service.dart';

final res = await HttpsServiceForNative.post(
  'https://192.168.1.1/api',
  {'key': 'value'},
  EnumPostEncodeType.json,
  // Allow certificate errors only in development (Debug mode)
  badCertificateCallback: (cert, host, port) => kDebugMode,
  connectionTimeout: const Duration(seconds: 5),
  responseTimeout: const Duration(seconds: 30),
);

switch (res.resultStatus) {
  case EnumServerResponseStatus.success:
    print(res.resBody);
  case EnumServerResponseStatus.timeout:
    print('timeout: ${res.errorDetail}');
  default:
    print('error: ${res.errorDetail}');
}

Native-only POST trusting a bundled certificate

You can trust a certificate bundled in your app (e.g. a self-signed certificate of a local server) instead of returning true unconditionally. Not available in Flutter web.

First, add the server certificate (PEM, never the private key) to your assets. Multiple certificates can be concatenated in one PEM file (e.g. old and new certificates during rotation).

flutter:
  assets:
    - assets/certs/local_server.pem

Then choose one of the following methods.

Condition Method Verification
The server's host name or IP is fixed and included in the certificate's SAN (recommended) UtilSecurityContext + securityContext Normal TLS verification (chain, host name, expiry)
The server's IP may change (e.g. DHCP), so the SAN cannot be fixed CertificatePinner + badCertificateCallback Byte-by-byte match with the bundled certificate only

Only the bundled certificates are trusted (public CAs are not trusted unless withTrustedRoots: true), and normal verification is performed. The certificate's SAN must contain the host name or IP you connect to.

import 'package:simple_https_service/simple_https_service.dart';

final context = await UtilSecurityContext.fromAsset('assets/certs/local_server.pem');

final res = await HttpsServiceForNative.post(
  'https://192.168.1.1/api',
  {'key': 'value'},
  EnumPostEncodeType.json,
  securityContext: context,
);

Method 2: CertificatePinner

Accepts the connection only when the server certificate exactly matches one of the bundled certificates.

import 'package:simple_https_service/simple_https_service.dart';

final pinner = await CertificatePinner.fromAsset('assets/certs/local_server.pem');

final res = await HttpsServiceForNative.post(
  'https://192.168.1.1/api',
  {'key': 'value'},
  EnumPostEncodeType.json,
  badCertificateCallback: pinner.check,
);

Note
badCertificateCallback is called when normal verification fails, and CertificatePinner accepts the certificate if it matches byte by byte. Therefore host name mismatch and expiry are not checked. Use Method 1 when possible. When you regenerate the server certificate, update the bundled asset as well.

Timing control

By default, all requests automatically pass through TimingManager (adjustTiming defaults to true). Each request waits until intervalMs milliseconds have elapsed since the previous request, preventing server-side rate limit errors.

// Default: automatically maintains a 1200ms minimum interval between requests.
await HttpsService.post(url, body, type);

// Disable timing control for a specific call.
await HttpsService.post(url, body, type, adjustTiming: false);

// Use a custom interval (milliseconds).
await HttpsService.post(url, body, type, intervalMs: 500);

Request cancellation

Pass a CancelToken to abort an in-flight request. Cancellation also interrupts the TimingManager wait and exponential-backoff delay between retries. A single token can be shared across multiple requests; one cancel() call aborts all of them, which is useful for scenarios such as "cancel every request issued from this screen on navigation away". Cancelled requests return with EnumServerResponseStatus.cancelled, so UI and error reporting code can distinguish them from real failures.

final token = CancelToken();

// Trigger cancellation from elsewhere, e.g. a back button:
// onPressed: () => token.cancel(),

final res = await HttpsService.post(
  'https://api.example.com/data',
  body,
  EnumPostEncodeType.json,
  cancelToken: token,
);

if (res.resultStatus == EnumServerResponseStatus.cancelled) {
  // User cancelled; suppress error toast / retry.
  return;
}

Automatic retry

// Global configuration (set once at app startup)
RetryConfig()
  ..maxRetries = 3
  ..baseDelay = const Duration(seconds: 1)
  ..maxJitter = const Duration(milliseconds: 500)
  ..defaultCondition = (url, res, error) {
    if (url.endsWith('/revoke')) return false;
    return error.toString().contains('Failed to fetch');
  };

// Per-call override
final res = await HttpsService.post(
  'https://api.example.com/data',
  body,
  EnumPostEncodeType.json,
  retryIf: (url, res, error) => res.resultStatus == EnumServerResponseStatus.timeout,
  maxRetries: 2,
);

The retry delay follows exponential backoff with optional jitter:
delay = baseDelay * 2^(n-1) + Random(0, maxJitter)

Support

Basically no support.
If you have any problem please open an issue on Github.
This package is low priority, but may be fixed.

About version control

The C part will be changed at the time of version upgrade.
However, versions less than 1.0.0 may change the file structure regardless of the following rules.

  • Changes such as adding variables, structure change that cause problems when reading previous files.
    • C.X.X
  • Adding methods, etc.
    • X.C.X
  • Minor changes and bug fixes.
    • X.X.C

License

Copyright 2026 Masahide Mori

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

   http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Trademarks

  • "Dart" and "Flutter" are trademarks of Google LLC.
    This package is not developed or endorsed by Google LLC.