A high-performance, enterprise-ready Spring Boot starter for sending push notifications via Expo.io.
- Multi-Backend Support: Send notifications via SQS (for scale), H2/JDBC (for persistence), or Local/In-Memory (for testing).
- Auto-Configuration: Drop the starter into your Spring Boot project and start sending notifications in minutes.
- Resilient: Built-in retry and rate-limiting logic to handle Expo API constraints.
- Type-Safe: Complete Java API for the Expo Push Ticket and Receipt models.
<dependency>
<groupId>dev.expopush</groupId>
<artifactId>expo-push-spring-boot-starter</artifactId>
<version>1.0.0-RC2</version>
</dependency>Add to your application.yaml:
expo:
push:
access-token: ${EXPO_ACCESS_TOKEN} # required — your Expo access token
backend: sqs # or h2, local
security:
# Payload encryption at rest is ON by default and needs a 256-bit Base64 key
# (generate one with: openssl rand -base64 32).
encryption-key: ${EXPO_ENCRYPTION_KEY}
# ...or opt out (title/body/metadata stored in plaintext on SQS/H2):
# encrypt-payloads: false
sqs:
push-queue-name: expo-push-notifications
receipt-queue-name: expo-push-receipts
region: us-east-1Every notification's terminal outcome is routed to the handler named in the command:
@Component
public class MyResultHandler implements NotificationResultHandler {
@Override
public String handlerId() {
return "my-handler"; // stable across deployments — it travels inside queue messages
}
@Override
public void handleResult(NotificationResult result) {
switch (result.outcome()) {
case ACCEPTED -> log.info("Delivered: {}", result.correlationId());
case REJECTED -> deactivateToken(result.pushToken()); // DeviceNotRegistered
case INVALID, UNKNOWN, FAILED -> log.warn("Not delivered: {}", result);
}
}
}@Autowired
private AsyncNotificationService notificationService;
public void notifyUser(String expoPushToken) {
notificationService.enqueue(new NotificationCommand(
expoPushToken,
"Hello", // title
"Hello World!", // body
UUID.randomUUID().toString(), // correlationId — echoed back in the result
Map.of(), // optional metadata, echoed back in the result
"my-handler" // handlerId of the result handler above
));
}Delivery options (custom data payload, Android channel, sound, ttl, badge, subtitle,
priority) go in an optional NotificationOptions:
notificationService.enqueue(new NotificationCommand(
expoPushToken, "Order update", "Your order shipped!",
correlationId, Map.of(), "my-handler",
new NotificationOptions(
Map.of("screen", "orders", "orderId", 4711), // data — delivered to the app
"order-updates", // Android channelId
"default", // iOS sound
3600, // ttl seconds
1, // iOS badge
"Order #4711", // iOS subtitle
NotificationPriority.HIGH
)
));On the persistent backends (SQS, H2), data and subtitle are encrypted at rest like
title and body.
When Micrometer is on the classpath and the application exposes a
MeterRegistry bean (e.g. via Spring Boot Actuator), the starter publishes:
| Meter | Type | Tags |
|---|---|---|
expo.push.submissions |
counter | status = accepted | rejected |
expo.push.results |
counter | outcome = accepted | rejected | invalid | unknown | failed |
expo.push.api.calls |
timer | operation = send | get-receipts, status = ok | error |
expo.push.local.receipt.queue.depth |
gauge | — |
expo.push.h2.pending.receipts |
gauge | — |
api.calls times each individual Expo HTTP attempt, so retries appear as extra attempts.
Without Micrometer, no metrics code is active — it's an optional dependency.
Full documentation is available at expopush.dev.
We welcome contributions! Please see our CONTRIBUTING.md for details on how to get started.
If you discover a security vulnerability, please follow our Security Policy.
Licensed under the Apache License, Version 2.0. See LICENSE for details.
Disclaimer: This project is an independent, open-source work and is not affiliated with, endorsed by, or sponsored by 650 Industries, Inc. or the official Expo project. "Expo" is a trademark of 650 Industries, Inc.