Repository files navigation

Curier

Curier is an asynchronous Web Push client for Arduino ESP32 with VAPID authentication, RFC 8291 aes128gcm payload encryption, and bounded work queues.

Curier is designed for firmware that needs to enqueue browser notifications without performing TLS, cryptography, or retry delays on the caller's task.

CIReleaseLicense: MIT

Why use Curier?

  • Modern Web Push - implements RFC 8291/8188 aes128gcm; the legacy aesgcm encoding is intentionally not supported.
  • Async ownership - accepted subscriptions, payloads, and callbacks are copied into a bounded queue and processed by Curier's own FreeRTOS task.
  • VAPID authentication - validates the configured P-256 key pair and signs ES256 JWTs with a small per-origin cache.
  • Explicit results - initialization and enqueue operations return CurierResult; terminal delivery uses CurierSendResult.
  • Controlled retries - fixed, exponential, or application-defined retry policies can honor Retry-After.
  • Time integration - uses standard system time by default, while allowing a provider to be registered before or after init().

Install

PlatformIO

Curier is built for Arduino ESP32 and depends on ArduinoJson v7.

[env:esp32dev]platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
https://github.com/ZekStack/curier.git
bblanchon/ArduinoJson@>=7.0.0
build_flags =
-std=gnu++20
build_unflags =
-std=gnu++11

Arduino IDE

Curier is not published to Arduino Library Manager yet.

Install it by downloading the repository ZIP or cloning it into:

Arduino/libraries/Curier

Install ArduinoJson v7 through Library Manager.

Quick start

#include<Arduino.h>
#include<Curier.h>
Curier curier;
voidsetup() {
CurierConfig config;
config.vapidConfig.subject = "mailto:notify@example.com";
config.vapidConfig.publicKeyBase64 = "BAvapidPublicKeyBase64Url...";
config.vapidConfig.privateKeyBase64 = "vapidPrivateKeyBase64Url...";
config.stackType = CurierStackType::Auto;
config.stackSize = 4096;
config.coreId = tskNO_AFFINITY;
config.queueSize = 16;
config.maxPayloadBytes = 3993;
config.ttlSeconds = 2419200;
CurierResult initResult = curier.init(config);
if (!initResult) {
return;
}
CurierSubscription subscription;
subscription.endpoint = "https://fcm.googleapis.com/fcm/send/...";
subscription.p256dh = "BME...";
subscription.auth = "nsa...";
CurierPayload payload;
payload.title = "Hello";
payload.body = "ESP32";
payload.tag = "demo";
payload.icon = "https://example.com/icon.png";
CurierResult queued = curier.send(
subscription,
payload,
[](CurierSendResult result) {
if (!result.ok()) {
ESP_LOGE(
"WEBPUSH",
"Push failed: %s (status %d)",
result.message,
result.statusCode
);
return;
}
ESP_LOGI("WEBPUSH", "Push OK (status %d)", result.statusCode);
}
);
if (!queued) {
ESP_LOGE("WEBPUSH", "Push was not queued: %s", queued.message);
}
}
voidloop() {
delay(1000);
}

Important notes

Important

Curier callbacks run on Curier's worker task. Keep callbacks short and protect application state shared with other tasks.

  • A successful send() means the job was accepted, not that the push service accepted it. The callback reports the terminal outcome.
  • Every accepted job receives exactly one callback before a successful end() returns. Queued and retrying jobs are completed as Cancelled during shutdown.
  • Calling end() from Curier's callback returns Busy. Destroying the Curier instance from that callback is a fatal programming error; schedule destruction on another application task.
  • A successful end() means the worker task has been deleted with the matching normal or capability-aware API and all owned runtime state has been released. A timeout preserves the Stopping state so a later call can finish cleanup.
  • queueSize bounds all accepted jobs, including the active job.
  • Subscriptions and JSON are validated and copied before send() returns.
  • Subscription endpoint authorities are validated before the HTTP request and VAPID audience are constructed. Malformed ports and unbracketed IPv6 are rejected.
  • The default clock is std::time(nullptr). Configure system time before delivery, for example with Tempo, SNTP, or another clock owner.
  • HTTPS verification is enabled by default through the ESP-IDF certificate bundle when that feature is available.
  • A VAPID private key is a device secret. Do not log it or include it in public firmware repositories.
  • Callbacks, time providers, and retry policies must not throw exceptions.

Time and retry behavior

Registering a time provider is optional. The provider may be installed before or after initialization:

curier.setTimeProvider([](uint64_t &epochSeconds) {
epochSeconds = static_cast<uint64_t>(std::time(nullptr));
return epochSeconds > 0;
});

Changing or clearing the provider invalidates cached VAPID JWTs. The provider runs on the worker task and must be thread-safe and non-blocking.

The default retry policy uses bounded exponential backoff with jitter for clock unavailability, transport errors, HTTP 408, HTTP 429, and HTTP 5xx responses. Permanent HTTP failures such as 400, 401, 403, and 404 are not retried.

config.retry.mode = CurierRetryMode::Exponential;
config.retry.maxRetries = 5;
config.retry.baseDelayMs = 1500;
config.retry.maxDelayMs = 15000;
config.retry.jitterPercent = 20;
config.retry.respectRetryAfter = true;

See docs/retries.md for a custom policy example.

Examples

ExampleDescription
BasicInitialize Curier and send a typed payload.
ArduinoJsonSend a validated ArduinoJson v7 document.
CustomTimeAndRetryRegister a clock provider and custom retry policy.

Start with:

examples/Basic

Documentation

DocumentDescription
docs/getting-started.mdVAPID, subscription, clock, and first-send setup.
docs/api.mdPublic API, result types, payloads, and callbacks.
docs/configuration.mdQueue, task, payload, TLS, TTL, and retry settings.
docs/concurrency.mdWorker ownership, callback context, and shutdown guarantees.
docs/retries.mdDefault and custom retry decisions.
docs/protocol.mdSupported Web Push protocol and wire behavior.
docs/security.mdKey handling, endpoint validation, TLS, and secret boundaries.
docs/memory.mdQueue ownership, transient allocations, and stack qualification.
docs/troubleshooting.mdCommon setup and delivery failures.

API overview

CurierResult init(const CurierConfig &config);
CurierResult send(
const CurierSubscription &subscription,
const CurierPayload &payload,
CurierSendCallback callback
);
CurierResult send(
const CurierSubscription &subscription,
const JsonDocument &payload,
CurierSendCallback callback
);
CurierResult setTimeProvider(CurierTimeProvider provider);
CurierResult clearTimeProvider();
CurierResult end(uint32_t timeoutMs = 5000);
CurierDiagnostics diagnostics() const;

Compatibility

ItemSupport
FrameworkArduino ESP32
LanguageC++20
NetworkingESP-IDF esp_http_client
EncryptionRFC 8291/8188 aes128gcm only
AuthenticationVAPID ES256
JSONArduinoJson >= 7.0.0
HTTPSCertificate bundle, global CA store, or custom PEM
Task stackInternal RAM, PSRAM where supported, or automatic fallback
ExceptionsNot used by Curier
Version0.1.0

Configuration

The defaults are intentionally bounded:

CurierConfig config;
config.queueSize = 16;
config.maxPayloadBytes = 3993;
config.maxEndpointBytes = 2048;
config.stackSize = 4096;
config.priority = 1;
config.coreId = tskNO_AFFINITY;
config.stackType = CurierStackType::Auto;
config.requestTimeoutMs = 10000;
config.ttlSeconds = 2419200;

maxPayloadBytes cannot exceed the 3993-byte single-record limit used by Curier. Encrypted bodies use a 4096-byte RFC 8188 record size.

Error handling

CurierResult queued = curier.send(subscription, payload, onPushComplete);
if (!queued) {
ESP_LOGE("WEBPUSH", "%s", queued.message);
}

Transport and delivery details are separate in the terminal result:

  • result.status describes the Curier-level outcome.
  • result.transportError contains the ESP-IDF transport error.
  • result.statusCode contains the HTTP response code when available.
  • result.attempts is the number of delivery attempts.

Release qualification

The host suite compares Curier's encrypted body byte-for-byte with the RFC 8291 Appendix A vector and independently verifies generated VAPID ES256 signatures. Target sketches exercise queue bounds, callbacks, retries, cancellation, timeout recovery, and repeated Internal, Auto, and PSRAM lifecycle cleanup. Run the target sketches on the production board before tagging a release.

License

MIT - see LICENSE.md and THIRD_PARTY_NOTICES.md.

ZekStack

Part of the ZekStack ESP32 library stack.

ZekStack libraries are designed to provide small, reusable building blocks for ESP32 applications.

About

An asynchronous Web Push client

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

Curier

Curier is an asynchronous Web Push client for Arduino ESP32 with VAPID authentication, RFC 8291 aes128gcm payload encryption, and bounded work queues.

Curier is designed for firmware that needs to enqueue browser notifications without performing TLS, cryptography, or retry delays on the caller's task.

CIReleaseLicense: MIT

Why use Curier?

  • Modern Web Push - implements RFC 8291/8188 aes128gcm; the legacy aesgcm encoding is intentionally not supported.
  • Async ownership - accepted subscriptions, payloads, and callbacks are copied into a bounded queue and processed by Curier's own FreeRTOS task.
  • VAPID authentication - validates the configured P-256 key pair and signs ES256 JWTs with a small per-origin cache.
  • Explicit results - initialization and enqueue operations return CurierResult; terminal delivery uses CurierSendResult.
  • Controlled retries - fixed, exponential, or application-defined retry policies can honor Retry-After.
  • Time integration - uses standard system time by default, while allowing a provider to be registered before or after init().

Install

PlatformIO

Curier is built for Arduino ESP32 and depends on ArduinoJson v7.

[env:esp32dev]platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
https://github.com/ZekStack/curier.git
bblanchon/ArduinoJson@>=7.0.0
build_flags =
-std=gnu++20
build_unflags =
-std=gnu++11

Arduino IDE

Curier is not published to Arduino Library Manager yet.

Install it by downloading the repository ZIP or cloning it into:

Arduino/libraries/Curier

Install ArduinoJson v7 through Library Manager.

Quick start

#include<Arduino.h>
#include<Curier.h>
Curier curier;
voidsetup() {
CurierConfig config;
config.vapidConfig.subject = "mailto:notify@example.com";
config.vapidConfig.publicKeyBase64 = "BAvapidPublicKeyBase64Url...";
config.vapidConfig.privateKeyBase64 = "vapidPrivateKeyBase64Url...";
config.stackType = CurierStackType::Auto;
config.stackSize = 4096;
config.coreId = tskNO_AFFINITY;
config.queueSize = 16;
config.maxPayloadBytes = 3993;
config.ttlSeconds = 2419200;
CurierResult initResult = curier.init(config);
if (!initResult) {
return;
}
CurierSubscription subscription;
subscription.endpoint = "https://fcm.googleapis.com/fcm/send/...";
subscription.p256dh = "BME...";
subscription.auth = "nsa...";
CurierPayload payload;
payload.title = "Hello";
payload.body = "ESP32";
payload.tag = "demo";
payload.icon = "https://example.com/icon.png";
CurierResult queued = curier.send(
subscription,
payload,
[](CurierSendResult result) {
if (!result.ok()) {
ESP_LOGE(
"WEBPUSH",
"Push failed: %s (status %d)",
result.message,
result.statusCode
);
return;
}
ESP_LOGI("WEBPUSH", "Push OK (status %d)", result.statusCode);
}
);
if (!queued) {
ESP_LOGE("WEBPUSH", "Push was not queued: %s", queued.message);
}
}
voidloop() {
delay(1000);
}

Important notes

Important

Curier callbacks run on Curier's worker task. Keep callbacks short and protect application state shared with other tasks.

  • A successful send() means the job was accepted, not that the push service accepted it. The callback reports the terminal outcome.
  • Every accepted job receives exactly one callback before a successful end() returns. Queued and retrying jobs are completed as Cancelled during shutdown.
  • Calling end() from Curier's callback returns Busy. Destroying the Curier instance from that callback is a fatal programming error; schedule destruction on another application task.
  • A successful end() means the worker task has been deleted with the matching normal or capability-aware API and all owned runtime state has been released. A timeout preserves the Stopping state so a later call can finish cleanup.
  • queueSize bounds all accepted jobs, including the active job.
  • Subscriptions and JSON are validated and copied before send() returns.
  • Subscription endpoint authorities are validated before the HTTP request and VAPID audience are constructed. Malformed ports and unbracketed IPv6 are rejected.
  • The default clock is std::time(nullptr). Configure system time before delivery, for example with Tempo, SNTP, or another clock owner.
  • HTTPS verification is enabled by default through the ESP-IDF certificate bundle when that feature is available.
  • A VAPID private key is a device secret. Do not log it or include it in public firmware repositories.
  • Callbacks, time providers, and retry policies must not throw exceptions.

Time and retry behavior

Registering a time provider is optional. The provider may be installed before or after initialization:

curier.setTimeProvider([](uint64_t &epochSeconds) {
epochSeconds = static_cast<uint64_t>(std::time(nullptr));
return epochSeconds > 0;
});

Changing or clearing the provider invalidates cached VAPID JWTs. The provider runs on the worker task and must be thread-safe and non-blocking.

The default retry policy uses bounded exponential backoff with jitter for clock unavailability, transport errors, HTTP 408, HTTP 429, and HTTP 5xx responses. Permanent HTTP failures such as 400, 401, 403, and 404 are not retried.

config.retry.mode = CurierRetryMode::Exponential;
config.retry.maxRetries = 5;
config.retry.baseDelayMs = 1500;
config.retry.maxDelayMs = 15000;
config.retry.jitterPercent = 20;
config.retry.respectRetryAfter = true;

See docs/retries.md for a custom policy example.

Examples

ExampleDescription
BasicInitialize Curier and send a typed payload.
ArduinoJsonSend a validated ArduinoJson v7 document.
CustomTimeAndRetryRegister a clock provider and custom retry policy.

Start with:

examples/Basic

Documentation

DocumentDescription
docs/getting-started.mdVAPID, subscription, clock, and first-send setup.
docs/api.mdPublic API, result types, payloads, and callbacks.
docs/configuration.mdQueue, task, payload, TLS, TTL, and retry settings.
docs/concurrency.mdWorker ownership, callback context, and shutdown guarantees.
docs/retries.mdDefault and custom retry decisions.
docs/protocol.mdSupported Web Push protocol and wire behavior.
docs/security.mdKey handling, endpoint validation, TLS, and secret boundaries.
docs/memory.mdQueue ownership, transient allocations, and stack qualification.
docs/troubleshooting.mdCommon setup and delivery failures.

API overview

CurierResult init(const CurierConfig &config);
CurierResult send(
const CurierSubscription &subscription,
const CurierPayload &payload,
CurierSendCallback callback
);
CurierResult send(
const CurierSubscription &subscription,
const JsonDocument &payload,
CurierSendCallback callback
);
CurierResult setTimeProvider(CurierTimeProvider provider);
CurierResult clearTimeProvider();
CurierResult end(uint32_t timeoutMs = 5000);
CurierDiagnostics diagnostics() const;

Compatibility

ItemSupport
FrameworkArduino ESP32
LanguageC++20
NetworkingESP-IDF esp_http_client
EncryptionRFC 8291/8188 aes128gcm only
AuthenticationVAPID ES256
JSONArduinoJson >= 7.0.0
HTTPSCertificate bundle, global CA store, or custom PEM
Task stackInternal RAM, PSRAM where supported, or automatic fallback
ExceptionsNot used by Curier
Version0.1.0

Configuration

The defaults are intentionally bounded:

CurierConfig config;
config.queueSize = 16;
config.maxPayloadBytes = 3993;
config.maxEndpointBytes = 2048;
config.stackSize = 4096;
config.priority = 1;
config.coreId = tskNO_AFFINITY;
config.stackType = CurierStackType::Auto;
config.requestTimeoutMs = 10000;
config.ttlSeconds = 2419200;

maxPayloadBytes cannot exceed the 3993-byte single-record limit used by Curier. Encrypted bodies use a 4096-byte RFC 8188 record size.

Error handling

CurierResult queued = curier.send(subscription, payload, onPushComplete);
if (!queued) {
ESP_LOGE("WEBPUSH", "%s", queued.message);
}

Transport and delivery details are separate in the terminal result:

  • result.status describes the Curier-level outcome.
  • result.transportError contains the ESP-IDF transport error.
  • result.statusCode contains the HTTP response code when available.
  • result.attempts is the number of delivery attempts.

Release qualification

The host suite compares Curier's encrypted body byte-for-byte with the RFC 8291 Appendix A vector and independently verifies generated VAPID ES256 signatures. Target sketches exercise queue bounds, callbacks, retries, cancellation, timeout recovery, and repeated Internal, Auto, and PSRAM lifecycle cleanup. Run the target sketches on the production board before tagging a release.

License

MIT - see LICENSE.md and THIRD_PARTY_NOTICES.md.

ZekStack

Part of the ZekStack ESP32 library stack.

ZekStack libraries are designed to provide small, reusable building blocks for ESP32 applications.

About

An asynchronous Web Push client

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Curier

Curier is an asynchronous Web Push client for Arduino ESP32 with VAPID authentication, RFC 8291 aes128gcm payload encryption, and bounded work queues.

Curier is designed for firmware that needs to enqueue browser notifications without performing TLS, cryptography, or retry delays on the caller's task.

CIReleaseLicense: MIT

Why use Curier?

  • Modern Web Push - implements RFC 8291/8188 aes128gcm; the legacy aesgcm encoding is intentionally not supported.
  • Async ownership - accepted subscriptions, payloads, and callbacks are copied into a bounded queue and processed by Curier's own FreeRTOS task.
  • VAPID authentication - validates the configured P-256 key pair and signs ES256 JWTs with a small per-origin cache.
  • Explicit results - initialization and enqueue operations return CurierResult; terminal delivery uses CurierSendResult.
  • Controlled retries - fixed, exponential, or application-defined retry policies can honor Retry-After.
  • Time integration - uses standard system time by default, while allowing a provider to be registered before or after init().

Install

PlatformIO

Curier is built for Arduino ESP32 and depends on ArduinoJson v7.

[env:esp32dev]platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
https://github.com/ZekStack/curier.git
bblanchon/ArduinoJson@>=7.0.0
build_flags =
-std=gnu++20
build_unflags =
-std=gnu++11

Arduino IDE

Curier is not published to Arduino Library Manager yet.

Install it by downloading the repository ZIP or cloning it into:

Arduino/libraries/Curier

Install ArduinoJson v7 through Library Manager.

Quick start

#include<Arduino.h>
#include<Curier.h>
Curier curier;
voidsetup() {
CurierConfig config;
config.vapidConfig.subject = "mailto:notify@example.com";
config.vapidConfig.publicKeyBase64 = "BAvapidPublicKeyBase64Url...";
config.vapidConfig.privateKeyBase64 = "vapidPrivateKeyBase64Url...";
config.stackType = CurierStackType::Auto;
config.stackSize = 4096;
config.coreId = tskNO_AFFINITY;
config.queueSize = 16;
config.maxPayloadBytes = 3993;
config.ttlSeconds = 2419200;
CurierResult initResult = curier.init(config);
if (!initResult) {
return;
}
CurierSubscription subscription;
subscription.endpoint = "https://fcm.googleapis.com/fcm/send/...";
subscription.p256dh = "BME...";
subscription.auth = "nsa...";
CurierPayload payload;
payload.title = "Hello";
payload.body = "ESP32";
payload.tag = "demo";
payload.icon = "https://example.com/icon.png";
CurierResult queued = curier.send(
subscription,
payload,
[](CurierSendResult result) {
if (!result.ok()) {
ESP_LOGE(
"WEBPUSH",
"Push failed: %s (status %d)",
result.message,
result.statusCode
);
return;
}
ESP_LOGI("WEBPUSH", "Push OK (status %d)", result.statusCode);
}
);
if (!queued) {
ESP_LOGE("WEBPUSH", "Push was not queued: %s", queued.message);
}
}
voidloop() {
delay(1000);
}

Important notes

Important

Curier callbacks run on Curier's worker task. Keep callbacks short and protect application state shared with other tasks.

  • A successful send() means the job was accepted, not that the push service accepted it. The callback reports the terminal outcome.
  • Every accepted job receives exactly one callback before a successful end() returns. Queued and retrying jobs are completed as Cancelled during shutdown.
  • Calling end() from Curier's callback returns Busy. Destroying the Curier instance from that callback is a fatal programming error; schedule destruction on another application task.
  • A successful end() means the worker task has been deleted with the matching normal or capability-aware API and all owned runtime state has been released. A timeout preserves the Stopping state so a later call can finish cleanup.
  • queueSize bounds all accepted jobs, including the active job.
  • Subscriptions and JSON are validated and copied before send() returns.
  • Subscription endpoint authorities are validated before the HTTP request and VAPID audience are constructed. Malformed ports and unbracketed IPv6 are rejected.
  • The default clock is std::time(nullptr). Configure system time before delivery, for example with Tempo, SNTP, or another clock owner.
  • HTTPS verification is enabled by default through the ESP-IDF certificate bundle when that feature is available.
  • A VAPID private key is a device secret. Do not log it or include it in public firmware repositories.
  • Callbacks, time providers, and retry policies must not throw exceptions.

Time and retry behavior

Registering a time provider is optional. The provider may be installed before or after initialization:

curier.setTimeProvider([](uint64_t &epochSeconds) {
epochSeconds = static_cast<uint64_t>(std::time(nullptr));
return epochSeconds > 0;
});

Changing or clearing the provider invalidates cached VAPID JWTs. The provider runs on the worker task and must be thread-safe and non-blocking.

The default retry policy uses bounded exponential backoff with jitter for clock unavailability, transport errors, HTTP 408, HTTP 429, and HTTP 5xx responses. Permanent HTTP failures such as 400, 401, 403, and 404 are not retried.

config.retry.mode = CurierRetryMode::Exponential;
config.retry.maxRetries = 5;
config.retry.baseDelayMs = 1500;
config.retry.maxDelayMs = 15000;
config.retry.jitterPercent = 20;
config.retry.respectRetryAfter = true;

See docs/retries.md for a custom policy example.

Examples

ExampleDescription
BasicInitialize Curier and send a typed payload.
ArduinoJsonSend a validated ArduinoJson v7 document.
CustomTimeAndRetryRegister a clock provider and custom retry policy.

Start with:

examples/Basic

Documentation

DocumentDescription
docs/getting-started.mdVAPID, subscription, clock, and first-send setup.
docs/api.mdPublic API, result types, payloads, and callbacks.
docs/configuration.mdQueue, task, payload, TLS, TTL, and retry settings.
docs/concurrency.mdWorker ownership, callback context, and shutdown guarantees.
docs/retries.mdDefault and custom retry decisions.
docs/protocol.mdSupported Web Push protocol and wire behavior.
docs/security.mdKey handling, endpoint validation, TLS, and secret boundaries.
docs/memory.mdQueue ownership, transient allocations, and stack qualification.
docs/troubleshooting.mdCommon setup and delivery failures.

API overview

CurierResult init(const CurierConfig &config);
CurierResult send(
const CurierSubscription &subscription,
const CurierPayload &payload,
CurierSendCallback callback
);
CurierResult send(
const CurierSubscription &subscription,
const JsonDocument &payload,
CurierSendCallback callback
);
CurierResult setTimeProvider(CurierTimeProvider provider);
CurierResult clearTimeProvider();
CurierResult end(uint32_t timeoutMs = 5000);
CurierDiagnostics diagnostics() const;

Compatibility

ItemSupport
FrameworkArduino ESP32
LanguageC++20
NetworkingESP-IDF esp_http_client
EncryptionRFC 8291/8188 aes128gcm only
AuthenticationVAPID ES256
JSONArduinoJson >= 7.0.0
HTTPSCertificate bundle, global CA store, or custom PEM
Task stackInternal RAM, PSRAM where supported, or automatic fallback
ExceptionsNot used by Curier
Version0.1.0

Configuration

The defaults are intentionally bounded:

CurierConfig config;
config.queueSize = 16;
config.maxPayloadBytes = 3993;
config.maxEndpointBytes = 2048;
config.stackSize = 4096;
config.priority = 1;
config.coreId = tskNO_AFFINITY;
config.stackType = CurierStackType::Auto;
config.requestTimeoutMs = 10000;
config.ttlSeconds = 2419200;

maxPayloadBytes cannot exceed the 3993-byte single-record limit used by Curier. Encrypted bodies use a 4096-byte RFC 8188 record size.

Error handling

CurierResult queued = curier.send(subscription, payload, onPushComplete);
if (!queued) {
ESP_LOGE("WEBPUSH", "%s", queued.message);
}

Transport and delivery details are separate in the terminal result:

  • result.status describes the Curier-level outcome.
  • result.transportError contains the ESP-IDF transport error.
  • result.statusCode contains the HTTP response code when available.
  • result.attempts is the number of delivery attempts.

Release qualification

The host suite compares Curier's encrypted body byte-for-byte with the RFC 8291 Appendix A vector and independently verifies generated VAPID ES256 signatures. Target sketches exercise queue bounds, callbacks, retries, cancellation, timeout recovery, and repeated Internal, Auto, and PSRAM lifecycle cleanup. Run the target sketches on the production board before tagging a release.

License

MIT - see LICENSE.md and THIRD_PARTY_NOTICES.md.

ZekStack

Part of the ZekStack ESP32 library stack.

ZekStack libraries are designed to provide small, reusable building blocks for ESP32 applications.

About

An asynchronous Web Push client

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Curier

Curier is an asynchronous Web Push client for Arduino ESP32 with VAPID authentication, RFC 8291 aes128gcm payload encryption, and bounded work queues.

Curier is designed for firmware that needs to enqueue browser notifications without performing TLS, cryptography, or retry delays on the caller's task.

CIReleaseLicense: MIT

Why use Curier?

  • Modern Web Push - implements RFC 8291/8188 aes128gcm; the legacy aesgcm encoding is intentionally not supported.
  • Async ownership - accepted subscriptions, payloads, and callbacks are copied into a bounded queue and processed by Curier's own FreeRTOS task.
  • VAPID authentication - validates the configured P-256 key pair and signs ES256 JWTs with a small per-origin cache.
  • Explicit results - initialization and enqueue operations return CurierResult; terminal delivery uses CurierSendResult.
  • Controlled retries - fixed, exponential, or application-defined retry policies can honor Retry-After.
  • Time integration - uses standard system time by default, while allowing a provider to be registered before or after init().

Install

PlatformIO

Curier is built for Arduino ESP32 and depends on ArduinoJson v7.

[env:esp32dev]platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
https://github.com/ZekStack/curier.git
bblanchon/ArduinoJson@>=7.0.0
build_flags =
-std=gnu++20
build_unflags =
-std=gnu++11

Arduino IDE

Curier is not published to Arduino Library Manager yet.

Install it by downloading the repository ZIP or cloning it into:

Arduino/libraries/Curier

Install ArduinoJson v7 through Library Manager.

Quick start

#include<Arduino.h>
#include<Curier.h>
Curier curier;
voidsetup() {
CurierConfig config;
config.vapidConfig.subject = "mailto:notify@example.com";
config.vapidConfig.publicKeyBase64 = "BAvapidPublicKeyBase64Url...";
config.vapidConfig.privateKeyBase64 = "vapidPrivateKeyBase64Url...";
config.stackType = CurierStackType::Auto;
config.stackSize = 4096;
config.coreId = tskNO_AFFINITY;
config.queueSize = 16;
config.maxPayloadBytes = 3993;
config.ttlSeconds = 2419200;
CurierResult initResult = curier.init(config);
if (!initResult) {
return;
}
CurierSubscription subscription;
subscription.endpoint = "https://fcm.googleapis.com/fcm/send/...";
subscription.p256dh = "BME...";
subscription.auth = "nsa...";
CurierPayload payload;
payload.title = "Hello";
payload.body = "ESP32";
payload.tag = "demo";
payload.icon = "https://example.com/icon.png";
CurierResult queued = curier.send(
subscription,
payload,
[](CurierSendResult result) {
if (!result.ok()) {
ESP_LOGE(
"WEBPUSH",
"Push failed: %s (status %d)",
result.message,
result.statusCode
);
return;
}
ESP_LOGI("WEBPUSH", "Push OK (status %d)", result.statusCode);
}
);
if (!queued) {
ESP_LOGE("WEBPUSH", "Push was not queued: %s", queued.message);
}
}
voidloop() {
delay(1000);
}

Important notes

Important

Curier callbacks run on Curier's worker task. Keep callbacks short and protect application state shared with other tasks.

  • A successful send() means the job was accepted, not that the push service accepted it. The callback reports the terminal outcome.
  • Every accepted job receives exactly one callback before a successful end() returns. Queued and retrying jobs are completed as Cancelled during shutdown.
  • Calling end() from Curier's callback returns Busy. Destroying the Curier instance from that callback is a fatal programming error; schedule destruction on another application task.
  • A successful end() means the worker task has been deleted with the matching normal or capability-aware API and all owned runtime state has been released. A timeout preserves the Stopping state so a later call can finish cleanup.
  • queueSize bounds all accepted jobs, including the active job.
  • Subscriptions and JSON are validated and copied before send() returns.
  • Subscription endpoint authorities are validated before the HTTP request and VAPID audience are constructed. Malformed ports and unbracketed IPv6 are rejected.
  • The default clock is std::time(nullptr). Configure system time before delivery, for example with Tempo, SNTP, or another clock owner.
  • HTTPS verification is enabled by default through the ESP-IDF certificate bundle when that feature is available.
  • A VAPID private key is a device secret. Do not log it or include it in public firmware repositories.
  • Callbacks, time providers, and retry policies must not throw exceptions.

Time and retry behavior

Registering a time provider is optional. The provider may be installed before or after initialization:

curier.setTimeProvider([](uint64_t &epochSeconds) {
epochSeconds = static_cast<uint64_t>(std::time(nullptr));
return epochSeconds > 0;
});

Changing or clearing the provider invalidates cached VAPID JWTs. The provider runs on the worker task and must be thread-safe and non-blocking.

The default retry policy uses bounded exponential backoff with jitter for clock unavailability, transport errors, HTTP 408, HTTP 429, and HTTP 5xx responses. Permanent HTTP failures such as 400, 401, 403, and 404 are not retried.

config.retry.mode = CurierRetryMode::Exponential;
config.retry.maxRetries = 5;
config.retry.baseDelayMs = 1500;
config.retry.maxDelayMs = 15000;
config.retry.jitterPercent = 20;
config.retry.respectRetryAfter = true;

See docs/retries.md for a custom policy example.

Examples

ExampleDescription
BasicInitialize Curier and send a typed payload.
ArduinoJsonSend a validated ArduinoJson v7 document.
CustomTimeAndRetryRegister a clock provider and custom retry policy.

Start with:

examples/Basic

Documentation

DocumentDescription
docs/getting-started.mdVAPID, subscription, clock, and first-send setup.
docs/api.mdPublic API, result types, payloads, and callbacks.
docs/configuration.mdQueue, task, payload, TLS, TTL, and retry settings.
docs/concurrency.mdWorker ownership, callback context, and shutdown guarantees.
docs/retries.mdDefault and custom retry decisions.
docs/protocol.mdSupported Web Push protocol and wire behavior.
docs/security.mdKey handling, endpoint validation, TLS, and secret boundaries.
docs/memory.mdQueue ownership, transient allocations, and stack qualification.
docs/troubleshooting.mdCommon setup and delivery failures.

API overview

CurierResult init(const CurierConfig &config);
CurierResult send(
const CurierSubscription &subscription,
const CurierPayload &payload,
CurierSendCallback callback
);
CurierResult send(
const CurierSubscription &subscription,
const JsonDocument &payload,
CurierSendCallback callback
);
CurierResult setTimeProvider(CurierTimeProvider provider);
CurierResult clearTimeProvider();
CurierResult end(uint32_t timeoutMs = 5000);
CurierDiagnostics diagnostics() const;

Compatibility

ItemSupport
FrameworkArduino ESP32
LanguageC++20
NetworkingESP-IDF esp_http_client
EncryptionRFC 8291/8188 aes128gcm only
AuthenticationVAPID ES256
JSONArduinoJson >= 7.0.0
HTTPSCertificate bundle, global CA store, or custom PEM
Task stackInternal RAM, PSRAM where supported, or automatic fallback
ExceptionsNot used by Curier
Version0.1.0

Configuration

The defaults are intentionally bounded:

CurierConfig config;
config.queueSize = 16;
config.maxPayloadBytes = 3993;
config.maxEndpointBytes = 2048;
config.stackSize = 4096;
config.priority = 1;
config.coreId = tskNO_AFFINITY;
config.stackType = CurierStackType::Auto;
config.requestTimeoutMs = 10000;
config.ttlSeconds = 2419200;

maxPayloadBytes cannot exceed the 3993-byte single-record limit used by Curier. Encrypted bodies use a 4096-byte RFC 8188 record size.

Error handling

CurierResult queued = curier.send(subscription, payload, onPushComplete);
if (!queued) {
ESP_LOGE("WEBPUSH", "%s", queued.message);
}

Transport and delivery details are separate in the terminal result:

  • result.status describes the Curier-level outcome.
  • result.transportError contains the ESP-IDF transport error.
  • result.statusCode contains the HTTP response code when available.
  • result.attempts is the number of delivery attempts.

Release qualification

The host suite compares Curier's encrypted body byte-for-byte with the RFC 8291 Appendix A vector and independently verifies generated VAPID ES256 signatures. Target sketches exercise queue bounds, callbacks, retries, cancellation, timeout recovery, and repeated Internal, Auto, and PSRAM lifecycle cleanup. Run the target sketches on the production board before tagging a release.

License

MIT - see LICENSE.md and THIRD_PARTY_NOTICES.md.

ZekStack

Part of the ZekStack ESP32 library stack.

ZekStack libraries are designed to provide small, reusable building blocks for ESP32 applications.

About

An asynchronous Web Push client

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

Curier

Curier is an asynchronous Web Push client for Arduino ESP32 with VAPID authentication, RFC 8291 aes128gcm payload encryption, and bounded work queues.

Curier is designed for firmware that needs to enqueue browser notifications without performing TLS, cryptography, or retry delays on the caller's task.

CIReleaseLicense: MIT

Why use Curier?

  • Modern Web Push - implements RFC 8291/8188 aes128gcm; the legacy aesgcm encoding is intentionally not supported.
  • Async ownership - accepted subscriptions, payloads, and callbacks are copied into a bounded queue and processed by Curier's own FreeRTOS task.
  • VAPID authentication - validates the configured P-256 key pair and signs ES256 JWTs with a small per-origin cache.
  • Explicit results - initialization and enqueue operations return CurierResult; terminal delivery uses CurierSendResult.
  • Controlled retries - fixed, exponential, or application-defined retry policies can honor Retry-After.
  • Time integration - uses standard system time by default, while allowing a provider to be registered before or after init().

Install

PlatformIO

Curier is built for Arduino ESP32 and depends on ArduinoJson v7.

[env:esp32dev]platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
https://github.com/ZekStack/curier.git
bblanchon/ArduinoJson@>=7.0.0
build_flags =
-std=gnu++20
build_unflags =
-std=gnu++11

Arduino IDE

Curier is not published to Arduino Library Manager yet.

Install it by downloading the repository ZIP or cloning it into:

Arduino/libraries/Curier

Install ArduinoJson v7 through Library Manager.

Quick start

#include<Arduino.h>
#include<Curier.h>
Curier curier;
voidsetup() {
CurierConfig config;
config.vapidConfig.subject = "mailto:notify@example.com";
config.vapidConfig.publicKeyBase64 = "BAvapidPublicKeyBase64Url...";
config.vapidConfig.privateKeyBase64 = "vapidPrivateKeyBase64Url...";
config.stackType = CurierStackType::Auto;
config.stackSize = 4096;
config.coreId = tskNO_AFFINITY;
config.queueSize = 16;
config.maxPayloadBytes = 3993;
config.ttlSeconds = 2419200;
CurierResult initResult = curier.init(config);
if (!initResult) {
return;
}
CurierSubscription subscription;
subscription.endpoint = "https://fcm.googleapis.com/fcm/send/...";
subscription.p256dh = "BME...";
subscription.auth = "nsa...";
CurierPayload payload;
payload.title = "Hello";
payload.body = "ESP32";
payload.tag = "demo";
payload.icon = "https://example.com/icon.png";
CurierResult queued = curier.send(
subscription,
payload,
[](CurierSendResult result) {
if (!result.ok()) {
ESP_LOGE(
"WEBPUSH",
"Push failed: %s (status %d)",
result.message,
result.statusCode
);
return;
}
ESP_LOGI("WEBPUSH", "Push OK (status %d)", result.statusCode);
}
);
if (!queued) {
ESP_LOGE("WEBPUSH", "Push was not queued: %s", queued.message);
}
}
voidloop() {
delay(1000);
}

Important notes

Important

Curier callbacks run on Curier's worker task. Keep callbacks short and protect application state shared with other tasks.

  • A successful send() means the job was accepted, not that the push service accepted it. The callback reports the terminal outcome.
  • Every accepted job receives exactly one callback before a successful end() returns. Queued and retrying jobs are completed as Cancelled during shutdown.
  • Calling end() from Curier's callback returns Busy. Destroying the Curier instance from that callback is a fatal programming error; schedule destruction on another application task.
  • A successful end() means the worker task has been deleted with the matching normal or capability-aware API and all owned runtime state has been released. A timeout preserves the Stopping state so a later call can finish cleanup.
  • queueSize bounds all accepted jobs, including the active job.
  • Subscriptions and JSON are validated and copied before send() returns.
  • Subscription endpoint authorities are validated before the HTTP request and VAPID audience are constructed. Malformed ports and unbracketed IPv6 are rejected.
  • The default clock is std::time(nullptr). Configure system time before delivery, for example with Tempo, SNTP, or another clock owner.
  • HTTPS verification is enabled by default through the ESP-IDF certificate bundle when that feature is available.
  • A VAPID private key is a device secret. Do not log it or include it in public firmware repositories.
  • Callbacks, time providers, and retry policies must not throw exceptions.

Time and retry behavior

Registering a time provider is optional. The provider may be installed before or after initialization:

curier.setTimeProvider([](uint64_t &epochSeconds) {
epochSeconds = static_cast<uint64_t>(std::time(nullptr));
return epochSeconds > 0;
});

Changing or clearing the provider invalidates cached VAPID JWTs. The provider runs on the worker task and must be thread-safe and non-blocking.

The default retry policy uses bounded exponential backoff with jitter for clock unavailability, transport errors, HTTP 408, HTTP 429, and HTTP 5xx responses. Permanent HTTP failures such as 400, 401, 403, and 404 are not retried.

config.retry.mode = CurierRetryMode::Exponential;
config.retry.maxRetries = 5;
config.retry.baseDelayMs = 1500;
config.retry.maxDelayMs = 15000;
config.retry.jitterPercent = 20;
config.retry.respectRetryAfter = true;

See docs/retries.md for a custom policy example.

Examples

ExampleDescription
BasicInitialize Curier and send a typed payload.
ArduinoJsonSend a validated ArduinoJson v7 document.
CustomTimeAndRetryRegister a clock provider and custom retry policy.

Start with:

examples/Basic

Documentation

DocumentDescription
docs/getting-started.mdVAPID, subscription, clock, and first-send setup.
docs/api.mdPublic API, result types, payloads, and callbacks.
docs/configuration.mdQueue, task, payload, TLS, TTL, and retry settings.
docs/concurrency.mdWorker ownership, callback context, and shutdown guarantees.
docs/retries.mdDefault and custom retry decisions.
docs/protocol.mdSupported Web Push protocol and wire behavior.
docs/security.mdKey handling, endpoint validation, TLS, and secret boundaries.
docs/memory.mdQueue ownership, transient allocations, and stack qualification.
docs/troubleshooting.mdCommon setup and delivery failures.

API overview

CurierResult init(const CurierConfig &config);
CurierResult send(
const CurierSubscription &subscription,
const CurierPayload &payload,
CurierSendCallback callback
);
CurierResult send(
const CurierSubscription &subscription,
const JsonDocument &payload,
CurierSendCallback callback
);
CurierResult setTimeProvider(CurierTimeProvider provider);
CurierResult clearTimeProvider();
CurierResult end(uint32_t timeoutMs = 5000);
CurierDiagnostics diagnostics() const;

Compatibility

ItemSupport
FrameworkArduino ESP32
LanguageC++20
NetworkingESP-IDF esp_http_client
EncryptionRFC 8291/8188 aes128gcm only
AuthenticationVAPID ES256
JSONArduinoJson >= 7.0.0
HTTPSCertificate bundle, global CA store, or custom PEM
Task stackInternal RAM, PSRAM where supported, or automatic fallback
ExceptionsNot used by Curier
Version0.1.0

Configuration

The defaults are intentionally bounded:

CurierConfig config;
config.queueSize = 16;
config.maxPayloadBytes = 3993;
config.maxEndpointBytes = 2048;
config.stackSize = 4096;
config.priority = 1;
config.coreId = tskNO_AFFINITY;
config.stackType = CurierStackType::Auto;
config.requestTimeoutMs = 10000;
config.ttlSeconds = 2419200;

maxPayloadBytes cannot exceed the 3993-byte single-record limit used by Curier. Encrypted bodies use a 4096-byte RFC 8188 record size.

Error handling

CurierResult queued = curier.send(subscription, payload, onPushComplete);
if (!queued) {
ESP_LOGE("WEBPUSH", "%s", queued.message);
}

Transport and delivery details are separate in the terminal result:

  • result.status describes the Curier-level outcome.
  • result.transportError contains the ESP-IDF transport error.
  • result.statusCode contains the HTTP response code when available.
  • result.attempts is the number of delivery attempts.

Release qualification

The host suite compares Curier's encrypted body byte-for-byte with the RFC 8291 Appendix A vector and independently verifies generated VAPID ES256 signatures. Target sketches exercise queue bounds, callbacks, retries, cancellation, timeout recovery, and repeated Internal, Auto, and PSRAM lifecycle cleanup. Run the target sketches on the production board before tagging a release.

License

MIT - see LICENSE.md and THIRD_PARTY_NOTICES.md.

ZekStack

Part of the ZekStack ESP32 library stack.

ZekStack libraries are designed to provide small, reusable building blocks for ESP32 applications.

About

An asynchronous Web Push client

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Curier

Curier is an asynchronous Web Push client for Arduino ESP32 with VAPID authentication, RFC 8291 aes128gcm payload encryption, and bounded work queues.

Curier is designed for firmware that needs to enqueue browser notifications without performing TLS, cryptography, or retry delays on the caller's task.

CIReleaseLicense: MIT

Why use Curier?

  • Modern Web Push - implements RFC 8291/8188 aes128gcm; the legacy aesgcm encoding is intentionally not supported.
  • Async ownership - accepted subscriptions, payloads, and callbacks are copied into a bounded queue and processed by Curier's own FreeRTOS task.
  • VAPID authentication - validates the configured P-256 key pair and signs ES256 JWTs with a small per-origin cache.
  • Explicit results - initialization and enqueue operations return CurierResult; terminal delivery uses CurierSendResult.
  • Controlled retries - fixed, exponential, or application-defined retry policies can honor Retry-After.
  • Time integration - uses standard system time by default, while allowing a provider to be registered before or after init().

Install

PlatformIO

Curier is built for Arduino ESP32 and depends on ArduinoJson v7.

[env:esp32dev]platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
https://github.com/ZekStack/curier.git
bblanchon/ArduinoJson@>=7.0.0
build_flags =
-std=gnu++20
build_unflags =
-std=gnu++11

Arduino IDE

Curier is not published to Arduino Library Manager yet.

Install it by downloading the repository ZIP or cloning it into:

Arduino/libraries/Curier

Install ArduinoJson v7 through Library Manager.

Quick start

#include<Arduino.h>
#include<Curier.h>
Curier curier;
voidsetup() {
CurierConfig config;
config.vapidConfig.subject = "mailto:notify@example.com";
config.vapidConfig.publicKeyBase64 = "BAvapidPublicKeyBase64Url...";
config.vapidConfig.privateKeyBase64 = "vapidPrivateKeyBase64Url...";
config.stackType = CurierStackType::Auto;
config.stackSize = 4096;
config.coreId = tskNO_AFFINITY;
config.queueSize = 16;
config.maxPayloadBytes = 3993;
config.ttlSeconds = 2419200;
CurierResult initResult = curier.init(config);
if (!initResult) {
return;
}
CurierSubscription subscription;
subscription.endpoint = "https://fcm.googleapis.com/fcm/send/...";
subscription.p256dh = "BME...";
subscription.auth = "nsa...";
CurierPayload payload;
payload.title = "Hello";
payload.body = "ESP32";
payload.tag = "demo";
payload.icon = "https://example.com/icon.png";
CurierResult queued = curier.send(
subscription,
payload,
[](CurierSendResult result) {
if (!result.ok()) {
ESP_LOGE(
"WEBPUSH",
"Push failed: %s (status %d)",
result.message,
result.statusCode
);
return;
}
ESP_LOGI("WEBPUSH", "Push OK (status %d)", result.statusCode);
}
);
if (!queued) {
ESP_LOGE("WEBPUSH", "Push was not queued: %s", queued.message);
}
}
voidloop() {
delay(1000);
}

Important notes

Important

Curier callbacks run on Curier's worker task. Keep callbacks short and protect application state shared with other tasks.

  • A successful send() means the job was accepted, not that the push service accepted it. The callback reports the terminal outcome.
  • Every accepted job receives exactly one callback before a successful end() returns. Queued and retrying jobs are completed as Cancelled during shutdown.
  • Calling end() from Curier's callback returns Busy. Destroying the Curier instance from that callback is a fatal programming error; schedule destruction on another application task.
  • A successful end() means the worker task has been deleted with the matching normal or capability-aware API and all owned runtime state has been released. A timeout preserves the Stopping state so a later call can finish cleanup.
  • queueSize bounds all accepted jobs, including the active job.
  • Subscriptions and JSON are validated and copied before send() returns.
  • Subscription endpoint authorities are validated before the HTTP request and VAPID audience are constructed. Malformed ports and unbracketed IPv6 are rejected.
  • The default clock is std::time(nullptr). Configure system time before delivery, for example with Tempo, SNTP, or another clock owner.
  • HTTPS verification is enabled by default through the ESP-IDF certificate bundle when that feature is available.
  • A VAPID private key is a device secret. Do not log it or include it in public firmware repositories.
  • Callbacks, time providers, and retry policies must not throw exceptions.

Time and retry behavior

Registering a time provider is optional. The provider may be installed before or after initialization:

curier.setTimeProvider([](uint64_t &epochSeconds) {
epochSeconds = static_cast<uint64_t>(std::time(nullptr));
return epochSeconds > 0;
});

Changing or clearing the provider invalidates cached VAPID JWTs. The provider runs on the worker task and must be thread-safe and non-blocking.

The default retry policy uses bounded exponential backoff with jitter for clock unavailability, transport errors, HTTP 408, HTTP 429, and HTTP 5xx responses. Permanent HTTP failures such as 400, 401, 403, and 404 are not retried.

config.retry.mode = CurierRetryMode::Exponential;
config.retry.maxRetries = 5;
config.retry.baseDelayMs = 1500;
config.retry.maxDelayMs = 15000;
config.retry.jitterPercent = 20;
config.retry.respectRetryAfter = true;

See docs/retries.md for a custom policy example.

Examples

ExampleDescription
BasicInitialize Curier and send a typed payload.
ArduinoJsonSend a validated ArduinoJson v7 document.
CustomTimeAndRetryRegister a clock provider and custom retry policy.

Start with:

examples/Basic

Documentation

DocumentDescription
docs/getting-started.mdVAPID, subscription, clock, and first-send setup.
docs/api.mdPublic API, result types, payloads, and callbacks.
docs/configuration.mdQueue, task, payload, TLS, TTL, and retry settings.
docs/concurrency.mdWorker ownership, callback context, and shutdown guarantees.
docs/retries.mdDefault and custom retry decisions.
docs/protocol.mdSupported Web Push protocol and wire behavior.
docs/security.mdKey handling, endpoint validation, TLS, and secret boundaries.
docs/memory.mdQueue ownership, transient allocations, and stack qualification.
docs/troubleshooting.mdCommon setup and delivery failures.

API overview

CurierResult init(const CurierConfig &config);
CurierResult send(
const CurierSubscription &subscription,
const CurierPayload &payload,
CurierSendCallback callback
);
CurierResult send(
const CurierSubscription &subscription,
const JsonDocument &payload,
CurierSendCallback callback
);
CurierResult setTimeProvider(CurierTimeProvider provider);
CurierResult clearTimeProvider();
CurierResult end(uint32_t timeoutMs = 5000);
CurierDiagnostics diagnostics() const;

Compatibility

ItemSupport
FrameworkArduino ESP32
LanguageC++20
NetworkingESP-IDF esp_http_client
EncryptionRFC 8291/8188 aes128gcm only
AuthenticationVAPID ES256
JSONArduinoJson >= 7.0.0
HTTPSCertificate bundle, global CA store, or custom PEM
Task stackInternal RAM, PSRAM where supported, or automatic fallback
ExceptionsNot used by Curier
Version0.1.0

Configuration

The defaults are intentionally bounded:

CurierConfig config;
config.queueSize = 16;
config.maxPayloadBytes = 3993;
config.maxEndpointBytes = 2048;
config.stackSize = 4096;
config.priority = 1;
config.coreId = tskNO_AFFINITY;
config.stackType = CurierStackType::Auto;
config.requestTimeoutMs = 10000;
config.ttlSeconds = 2419200;

maxPayloadBytes cannot exceed the 3993-byte single-record limit used by Curier. Encrypted bodies use a 4096-byte RFC 8188 record size.

Error handling

CurierResult queued = curier.send(subscription, payload, onPushComplete);
if (!queued) {
ESP_LOGE("WEBPUSH", "%s", queued.message);
}

Transport and delivery details are separate in the terminal result:

  • result.status describes the Curier-level outcome.
  • result.transportError contains the ESP-IDF transport error.
  • result.statusCode contains the HTTP response code when available.
  • result.attempts is the number of delivery attempts.

Release qualification

The host suite compares Curier's encrypted body byte-for-byte with the RFC 8291 Appendix A vector and independently verifies generated VAPID ES256 signatures. Target sketches exercise queue bounds, callbacks, retries, cancellation, timeout recovery, and repeated Internal, Auto, and PSRAM lifecycle cleanup. Run the target sketches on the production board before tagging a release.

License

MIT - see LICENSE.md and THIRD_PARTY_NOTICES.md.

ZekStack

Part of the ZekStack ESP32 library stack.

ZekStack libraries are designed to provide small, reusable building blocks for ESP32 applications.

About

An asynchronous Web Push client

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Curier

Curier is an asynchronous Web Push client for Arduino ESP32 with VAPID authentication, RFC 8291 aes128gcm payload encryption, and bounded work queues.

Curier is designed for firmware that needs to enqueue browser notifications without performing TLS, cryptography, or retry delays on the caller's task.

CIReleaseLicense: MIT

Why use Curier?

  • Modern Web Push - implements RFC 8291/8188 aes128gcm; the legacy aesgcm encoding is intentionally not supported.
  • Async ownership - accepted subscriptions, payloads, and callbacks are copied into a bounded queue and processed by Curier's own FreeRTOS task.
  • VAPID authentication - validates the configured P-256 key pair and signs ES256 JWTs with a small per-origin cache.
  • Explicit results - initialization and enqueue operations return CurierResult; terminal delivery uses CurierSendResult.
  • Controlled retries - fixed, exponential, or application-defined retry policies can honor Retry-After.
  • Time integration - uses standard system time by default, while allowing a provider to be registered before or after init().

Install

PlatformIO

Curier is built for Arduino ESP32 and depends on ArduinoJson v7.

[env:esp32dev]platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
https://github.com/ZekStack/curier.git
bblanchon/ArduinoJson@>=7.0.0
build_flags =
-std=gnu++20
build_unflags =
-std=gnu++11

Arduino IDE

Curier is not published to Arduino Library Manager yet.

Install it by downloading the repository ZIP or cloning it into:

Arduino/libraries/Curier

Install ArduinoJson v7 through Library Manager.

Quick start

#include<Arduino.h>
#include<Curier.h>
Curier curier;
voidsetup() {
CurierConfig config;
config.vapidConfig.subject = "mailto:notify@example.com";
config.vapidConfig.publicKeyBase64 = "BAvapidPublicKeyBase64Url...";
config.vapidConfig.privateKeyBase64 = "vapidPrivateKeyBase64Url...";
config.stackType = CurierStackType::Auto;
config.stackSize = 4096;
config.coreId = tskNO_AFFINITY;
config.queueSize = 16;
config.maxPayloadBytes = 3993;
config.ttlSeconds = 2419200;
CurierResult initResult = curier.init(config);
if (!initResult) {
return;
}
CurierSubscription subscription;
subscription.endpoint = "https://fcm.googleapis.com/fcm/send/...";
subscription.p256dh = "BME...";
subscription.auth = "nsa...";
CurierPayload payload;
payload.title = "Hello";
payload.body = "ESP32";
payload.tag = "demo";
payload.icon = "https://example.com/icon.png";
CurierResult queued = curier.send(
subscription,
payload,
[](CurierSendResult result) {
if (!result.ok()) {
ESP_LOGE(
"WEBPUSH",
"Push failed: %s (status %d)",
result.message,
result.statusCode
);
return;
}
ESP_LOGI("WEBPUSH", "Push OK (status %d)", result.statusCode);
}
);
if (!queued) {
ESP_LOGE("WEBPUSH", "Push was not queued: %s", queued.message);
}
}
voidloop() {
delay(1000);
}

Important notes

Important

Curier callbacks run on Curier's worker task. Keep callbacks short and protect application state shared with other tasks.

  • A successful send() means the job was accepted, not that the push service accepted it. The callback reports the terminal outcome.
  • Every accepted job receives exactly one callback before a successful end() returns. Queued and retrying jobs are completed as Cancelled during shutdown.
  • Calling end() from Curier's callback returns Busy. Destroying the Curier instance from that callback is a fatal programming error; schedule destruction on another application task.
  • A successful end() means the worker task has been deleted with the matching normal or capability-aware API and all owned runtime state has been released. A timeout preserves the Stopping state so a later call can finish cleanup.
  • queueSize bounds all accepted jobs, including the active job.
  • Subscriptions and JSON are validated and copied before send() returns.
  • Subscription endpoint authorities are validated before the HTTP request and VAPID audience are constructed. Malformed ports and unbracketed IPv6 are rejected.
  • The default clock is std::time(nullptr). Configure system time before delivery, for example with Tempo, SNTP, or another clock owner.
  • HTTPS verification is enabled by default through the ESP-IDF certificate bundle when that feature is available.
  • A VAPID private key is a device secret. Do not log it or include it in public firmware repositories.
  • Callbacks, time providers, and retry policies must not throw exceptions.

Time and retry behavior

Registering a time provider is optional. The provider may be installed before or after initialization:

curier.setTimeProvider([](uint64_t &epochSeconds) {
epochSeconds = static_cast<uint64_t>(std::time(nullptr));
return epochSeconds > 0;
});

Changing or clearing the provider invalidates cached VAPID JWTs. The provider runs on the worker task and must be thread-safe and non-blocking.

The default retry policy uses bounded exponential backoff with jitter for clock unavailability, transport errors, HTTP 408, HTTP 429, and HTTP 5xx responses. Permanent HTTP failures such as 400, 401, 403, and 404 are not retried.

config.retry.mode = CurierRetryMode::Exponential;
config.retry.maxRetries = 5;
config.retry.baseDelayMs = 1500;
config.retry.maxDelayMs = 15000;
config.retry.jitterPercent = 20;
config.retry.respectRetryAfter = true;

See docs/retries.md for a custom policy example.

Examples

ExampleDescription
BasicInitialize Curier and send a typed payload.
ArduinoJsonSend a validated ArduinoJson v7 document.
CustomTimeAndRetryRegister a clock provider and custom retry policy.

Start with:

examples/Basic

Documentation

DocumentDescription
docs/getting-started.mdVAPID, subscription, clock, and first-send setup.
docs/api.mdPublic API, result types, payloads, and callbacks.
docs/configuration.mdQueue, task, payload, TLS, TTL, and retry settings.
docs/concurrency.mdWorker ownership, callback context, and shutdown guarantees.
docs/retries.mdDefault and custom retry decisions.
docs/protocol.mdSupported Web Push protocol and wire behavior.
docs/security.mdKey handling, endpoint validation, TLS, and secret boundaries.
docs/memory.mdQueue ownership, transient allocations, and stack qualification.
docs/troubleshooting.mdCommon setup and delivery failures.

API overview

CurierResult init(const CurierConfig &config);
CurierResult send(
const CurierSubscription &subscription,
const CurierPayload &payload,
CurierSendCallback callback
);
CurierResult send(
const CurierSubscription &subscription,
const JsonDocument &payload,
CurierSendCallback callback
);
CurierResult setTimeProvider(CurierTimeProvider provider);
CurierResult clearTimeProvider();
CurierResult end(uint32_t timeoutMs = 5000);
CurierDiagnostics diagnostics() const;

Compatibility

ItemSupport
FrameworkArduino ESP32
LanguageC++20
NetworkingESP-IDF esp_http_client
EncryptionRFC 8291/8188 aes128gcm only
AuthenticationVAPID ES256
JSONArduinoJson >= 7.0.0
HTTPSCertificate bundle, global CA store, or custom PEM
Task stackInternal RAM, PSRAM where supported, or automatic fallback
ExceptionsNot used by Curier
Version0.1.0

Configuration

The defaults are intentionally bounded:

CurierConfig config;
config.queueSize = 16;
config.maxPayloadBytes = 3993;
config.maxEndpointBytes = 2048;
config.stackSize = 4096;
config.priority = 1;
config.coreId = tskNO_AFFINITY;
config.stackType = CurierStackType::Auto;
config.requestTimeoutMs = 10000;
config.ttlSeconds = 2419200;

maxPayloadBytes cannot exceed the 3993-byte single-record limit used by Curier. Encrypted bodies use a 4096-byte RFC 8188 record size.

Error handling

CurierResult queued = curier.send(subscription, payload, onPushComplete);
if (!queued) {
ESP_LOGE("WEBPUSH", "%s", queued.message);
}

Transport and delivery details are separate in the terminal result:

  • result.status describes the Curier-level outcome.
  • result.transportError contains the ESP-IDF transport error.
  • result.statusCode contains the HTTP response code when available.
  • result.attempts is the number of delivery attempts.

Release qualification

The host suite compares Curier's encrypted body byte-for-byte with the RFC 8291 Appendix A vector and independently verifies generated VAPID ES256 signatures. Target sketches exercise queue bounds, callbacks, retries, cancellation, timeout recovery, and repeated Internal, Auto, and PSRAM lifecycle cleanup. Run the target sketches on the production board before tagging a release.

License

MIT - see LICENSE.md and THIRD_PARTY_NOTICES.md.

ZekStack

Part of the ZekStack ESP32 library stack.

ZekStack libraries are designed to provide small, reusable building blocks for ESP32 applications.

About

An asynchronous Web Push client

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

Curier

Curier is an asynchronous Web Push client for Arduino ESP32 with VAPID authentication, RFC 8291 aes128gcm payload encryption, and bounded work queues.

Curier is designed for firmware that needs to enqueue browser notifications without performing TLS, cryptography, or retry delays on the caller's task.

CIReleaseLicense: MIT

Why use Curier?

  • Modern Web Push - implements RFC 8291/8188 aes128gcm; the legacy aesgcm encoding is intentionally not supported.
  • Async ownership - accepted subscriptions, payloads, and callbacks are copied into a bounded queue and processed by Curier's own FreeRTOS task.
  • VAPID authentication - validates the configured P-256 key pair and signs ES256 JWTs with a small per-origin cache.
  • Explicit results - initialization and enqueue operations return CurierResult; terminal delivery uses CurierSendResult.
  • Controlled retries - fixed, exponential, or application-defined retry policies can honor Retry-After.
  • Time integration - uses standard system time by default, while allowing a provider to be registered before or after init().

Install

PlatformIO

Curier is built for Arduino ESP32 and depends on ArduinoJson v7.

[env:esp32dev]platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
https://github.com/ZekStack/curier.git
bblanchon/ArduinoJson@>=7.0.0
build_flags =
-std=gnu++20
build_unflags =
-std=gnu++11

Arduino IDE

Curier is not published to Arduino Library Manager yet.

Install it by downloading the repository ZIP or cloning it into:

Arduino/libraries/Curier

Install ArduinoJson v7 through Library Manager.

Quick start

#include<Arduino.h>
#include<Curier.h>
Curier curier;
voidsetup() {
CurierConfig config;
config.vapidConfig.subject = "mailto:notify@example.com";
config.vapidConfig.publicKeyBase64 = "BAvapidPublicKeyBase64Url...";
config.vapidConfig.privateKeyBase64 = "vapidPrivateKeyBase64Url...";
config.stackType = CurierStackType::Auto;
config.stackSize = 4096;
config.coreId = tskNO_AFFINITY;
config.queueSize = 16;
config.maxPayloadBytes = 3993;
config.ttlSeconds = 2419200;
CurierResult initResult = curier.init(config);
if (!initResult) {
return;
}
CurierSubscription subscription;
subscription.endpoint = "https://fcm.googleapis.com/fcm/send/...";
subscription.p256dh = "BME...";
subscription.auth = "nsa...";
CurierPayload payload;
payload.title = "Hello";
payload.body = "ESP32";
payload.tag = "demo";
payload.icon = "https://example.com/icon.png";
CurierResult queued = curier.send(
subscription,
payload,
[](CurierSendResult result) {
if (!result.ok()) {
ESP_LOGE(
"WEBPUSH",
"Push failed: %s (status %d)",
result.message,
result.statusCode
);
return;
}
ESP_LOGI("WEBPUSH", "Push OK (status %d)", result.statusCode);
}
);
if (!queued) {
ESP_LOGE("WEBPUSH", "Push was not queued: %s", queued.message);
}
}
voidloop() {
delay(1000);
}

Important notes

Important

Curier callbacks run on Curier's worker task. Keep callbacks short and protect application state shared with other tasks.

  • A successful send() means the job was accepted, not that the push service accepted it. The callback reports the terminal outcome.
  • Every accepted job receives exactly one callback before a successful end() returns. Queued and retrying jobs are completed as Cancelled during shutdown.
  • Calling end() from Curier's callback returns Busy. Destroying the Curier instance from that callback is a fatal programming error; schedule destruction on another application task.
  • A successful end() means the worker task has been deleted with the matching normal or capability-aware API and all owned runtime state has been released. A timeout preserves the Stopping state so a later call can finish cleanup.
  • queueSize bounds all accepted jobs, including the active job.
  • Subscriptions and JSON are validated and copied before send() returns.
  • Subscription endpoint authorities are validated before the HTTP request and VAPID audience are constructed. Malformed ports and unbracketed IPv6 are rejected.
  • The default clock is std::time(nullptr). Configure system time before delivery, for example with Tempo, SNTP, or another clock owner.
  • HTTPS verification is enabled by default through the ESP-IDF certificate bundle when that feature is available.
  • A VAPID private key is a device secret. Do not log it or include it in public firmware repositories.
  • Callbacks, time providers, and retry policies must not throw exceptions.

Time and retry behavior

Registering a time provider is optional. The provider may be installed before or after initialization:

curier.setTimeProvider([](uint64_t &epochSeconds) {
epochSeconds = static_cast<uint64_t>(std::time(nullptr));
return epochSeconds > 0;
});

Changing or clearing the provider invalidates cached VAPID JWTs. The provider runs on the worker task and must be thread-safe and non-blocking.

The default retry policy uses bounded exponential backoff with jitter for clock unavailability, transport errors, HTTP 408, HTTP 429, and HTTP 5xx responses. Permanent HTTP failures such as 400, 401, 403, and 404 are not retried.

config.retry.mode = CurierRetryMode::Exponential;
config.retry.maxRetries = 5;
config.retry.baseDelayMs = 1500;
config.retry.maxDelayMs = 15000;
config.retry.jitterPercent = 20;
config.retry.respectRetryAfter = true;

See docs/retries.md for a custom policy example.

Examples

ExampleDescription
BasicInitialize Curier and send a typed payload.
ArduinoJsonSend a validated ArduinoJson v7 document.
CustomTimeAndRetryRegister a clock provider and custom retry policy.

Start with:

examples/Basic

Documentation

DocumentDescription
docs/getting-started.mdVAPID, subscription, clock, and first-send setup.
docs/api.mdPublic API, result types, payloads, and callbacks.
docs/configuration.mdQueue, task, payload, TLS, TTL, and retry settings.
docs/concurrency.mdWorker ownership, callback context, and shutdown guarantees.
docs/retries.mdDefault and custom retry decisions.
docs/protocol.mdSupported Web Push protocol and wire behavior.
docs/security.mdKey handling, endpoint validation, TLS, and secret boundaries.
docs/memory.mdQueue ownership, transient allocations, and stack qualification.
docs/troubleshooting.mdCommon setup and delivery failures.

API overview

CurierResult init(const CurierConfig &config);
CurierResult send(
const CurierSubscription &subscription,
const CurierPayload &payload,
CurierSendCallback callback
);
CurierResult send(
const CurierSubscription &subscription,
const JsonDocument &payload,
CurierSendCallback callback
);
CurierResult setTimeProvider(CurierTimeProvider provider);
CurierResult clearTimeProvider();
CurierResult end(uint32_t timeoutMs = 5000);
CurierDiagnostics diagnostics() const;

Compatibility

ItemSupport
FrameworkArduino ESP32
LanguageC++20
NetworkingESP-IDF esp_http_client
EncryptionRFC 8291/8188 aes128gcm only
AuthenticationVAPID ES256
JSONArduinoJson >= 7.0.0
HTTPSCertificate bundle, global CA store, or custom PEM
Task stackInternal RAM, PSRAM where supported, or automatic fallback
ExceptionsNot used by Curier
Version0.1.0

Configuration

The defaults are intentionally bounded:

CurierConfig config;
config.queueSize = 16;
config.maxPayloadBytes = 3993;
config.maxEndpointBytes = 2048;
config.stackSize = 4096;
config.priority = 1;
config.coreId = tskNO_AFFINITY;
config.stackType = CurierStackType::Auto;
config.requestTimeoutMs = 10000;
config.ttlSeconds = 2419200;

maxPayloadBytes cannot exceed the 3993-byte single-record limit used by Curier. Encrypted bodies use a 4096-byte RFC 8188 record size.

Error handling

CurierResult queued = curier.send(subscription, payload, onPushComplete);
if (!queued) {
ESP_LOGE("WEBPUSH", "%s", queued.message);
}

Transport and delivery details are separate in the terminal result:

  • result.status describes the Curier-level outcome.
  • result.transportError contains the ESP-IDF transport error.
  • result.statusCode contains the HTTP response code when available.
  • result.attempts is the number of delivery attempts.

Release qualification

The host suite compares Curier's encrypted body byte-for-byte with the RFC 8291 Appendix A vector and independently verifies generated VAPID ES256 signatures. Target sketches exercise queue bounds, callbacks, retries, cancellation, timeout recovery, and repeated Internal, Auto, and PSRAM lifecycle cleanup. Run the target sketches on the production board before tagging a release.

License

MIT - see LICENSE.md and THIRD_PARTY_NOTICES.md.

ZekStack

Part of the ZekStack ESP32 library stack.

ZekStack libraries are designed to provide small, reusable building blocks for ESP32 applications.

About

An asynchronous Web Push client

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages