A production-quality, open-source engagement backend for live streaming platforms. TipCurrent handles tips and other engagement events that drive interaction and monetization in gaming, live events, webinars, and creator content.
TipCurrent provides a reliable, self-hosted solution for ingesting and persisting engagement events from live streaming platforms. This initial version focuses on accepting tip events via HTTP and storing them durably in PostgreSQL.
- REST API for creating and querying tip events
- Real-time WebSocket broadcasting for tip events
- Webhooks for event notifications with HMAC signature verification
- Production-quality Analytics API with pre-aggregated summary tables
- Scheduled hourly aggregation for OLTP/OLAP separation
- PostgreSQL persistence with proper indexing
- Docker Compose for easy local development
- Integration tests with Testcontainers
- Built with Spring Boot 4.0.1 and Java 25
- Java 25 or higher
- Maven 3.9+
- Docker and Docker Compose
- curl or similar HTTP client (for testing)
docker-compose up -dThis starts a PostgreSQL 17 container with the database pre-configured.
On macOS/Linux:
./mvnw clean packageOn Windows:
mvnw.cmd clean packageOn macOS/Linux:
./mvnw spring-boot:runOn Windows:
mvnw.cmd spring-boot:runThe service will start on http://localhost:8080.
curl -X POST http://localhost:8080/api/tips \
-H "Content-Type: application/json" \
-d '{
"roomId": "gaming_stream_123",
"senderId": "alice",
"recipientId": "bob",
"amount": 100.00,
"message": "Great play!",
"metadata": "{\"type\":\"celebration\"}"
}'Expected response:
{
"id": 1,
"roomId": "gaming_stream_123",
"senderId": "alice",
"recipientId": "bob",
"amount": 100.00,
"message": "Great play!",
"metadata": "{\"type\":\"celebration\"}",
"createdAt": "2024-01-15T10:30:45.123Z"
}Endpoint: POST /api/tips
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
| roomId | string | Yes | Identifier for the room/stream where the tip occurred |
| senderId | string | Yes | Identifier for the user sending the tip |
| recipientId | string | Yes | Identifier for the user receiving the tip |
| amount | decimal | Yes | Tip amount (e.g., tokens, currency units) |
| message | string | No | Optional message from sender (max 1000 chars) |
| metadata | string | No | Optional JSON metadata for additional context |
Response: HTTP 201 Created with the persisted tip including generated ID and timestamp.
Endpoint: GET /api/tips
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| roomId | string | No | Filter tips by room ID |
| senderId | string | No | Filter tips by sender ID |
| recipientId | string | No | Filter tips by recipient ID |
| page | integer | No | Page number (default: 0) |
| size | integer | No | Page size (default: 20, max: 100) |
Response: HTTP 200 OK with paginated list of tips, sorted by createdAt (newest first).
Examples:
Get all tips (paginated):
curl http://localhost:8080/api/tipsGet tips for a specific room:
curl http://localhost:8080/api/tips?roomId=gaming_stream_123Get tips received by a user:
curl http://localhost:8080/api/tips?recipientId=bobGet tips with pagination:
curl http://localhost:8080/api/tips?page=0&size=10Combine filters:
curl http://localhost:8080/api/tips?roomId=gaming_stream_123&recipientId=bobResponse Format:
{
"content": [
{
"id": 2,
"roomId": "gaming_stream_123",
"senderId": "charlie",
"recipientId": "bob",
"amount": 200.00,
"message": "Amazing!",
"metadata": null,
"createdAt": "2024-01-15T10:35:00.000Z"
},
{
"id": 1,
"roomId": "gaming_stream_123",
"senderId": "alice",
"recipientId": "bob",
"amount": 100.00,
"message": "Great play!",
"metadata": "{\"type\":\"celebration\"}",
"createdAt": "2024-01-15T10:30:45.123Z"
}
],
"pageable": {
"pageNumber": 0,
"pageSize": 20
},
"totalElements": 2,
"totalPages": 1,
"last": true,
"first": true
}Endpoint: GET /api/tips/{id}
Path Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | long | Yes | The tip ID |
Response: HTTP 200 OK with the tip details, or HTTP 404 Not Found if the tip doesn't exist.
Example:
curl http://localhost:8080/api/tips/1Response:
{
"id": 1,
"roomId": "gaming_stream_123",
"senderId": "alice",
"recipientId": "bob",
"amount": 100.00,
"message": "Great play!",
"metadata": "{\"type\":\"celebration\"}",
"createdAt": "2024-01-15T10:30:45.123Z"
}TipCurrent provides real-time tip event broadcasting using WebSocket with STOMP protocol. When a tip is created via the REST API, it's automatically broadcast to all WebSocket subscribers in the same room.
Connect to: ws://localhost:8080/ws
Subscribe to room-specific topics to receive tip events:
Topic: /topic/rooms/{roomId}
Where {roomId} is the room identifier (e.g., gaming_stream_123).
// Using SockJS and STOMP.js
const socket = new SockJS('http://localhost:8080/ws');
const stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
console.log('Connected: ' + frame);
// Subscribe to a specific room
stompClient.subscribe('/topic/rooms/gaming_stream_123', function(message) {
const tip = JSON.parse(message.body);
console.log('Received tip:', tip);
// Handle the tip event (update UI, play sound, etc.)
});
});WebSocket messages contain the same TipResponse format as the REST API:
{
"id": 1,
"roomId": "gaming_stream_123",
"senderId": "alice",
"recipientId": "bob",
"amount": 100.00,
"message": "Great play!",
"metadata": "{\"type\":\"celebration\"}",
"createdAt": "2024-01-15T10:30:45.123Z"
}- Live Stream Overlays: Display tips in real-time on stream
- Audience Engagement: Show tip notifications to viewers
- Creator Dashboards: Real-time revenue and tip tracking
- Moderation Tools: Monitor tip activity across rooms
<!DOCTYPE html>
<html>
<head>
<title>TipCurrent WebSocket Demo</title>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/stomp.min.js"></script>
</head>
<body>
<h1>Room: gaming_stream_123</h1>
<div id="tips"></div>
<script>
const socket = new SockJS('http://localhost:8080/ws');
const stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
console.log('Connected to TipCurrent');
stompClient.subscribe('/topic/rooms/gaming_stream_123', function(message) {
const tip = JSON.parse(message.body);
displayTip(tip);
});
});
function displayTip(tip) {
const tipsDiv = document.getElementById('tips');
const tipElement = document.createElement('div');
tipElement.innerHTML = `
<strong>${tip.senderId}</strong> tipped
<strong>${tip.recipientId}</strong>
${tip.amount} tokens
${tip.message ? ': ' + tip.message : ''}
`;
tipsDiv.prepend(tipElement);
}
</script>
</body>
</html>TipCurrent provides production-quality analytics using pre-aggregated summary tables. This architecture separates OLTP (transactional writes) from OLAP (analytical queries), ensuring excellent performance for both operations.
The analytics system uses a scheduled aggregation pattern:
Tip Creation → tips table (OLTP, write-optimized)
↓
@Scheduled job (runs hourly at :05)
↓
room_stats_hourly (pre-aggregated summary table)
↓
Analytics API → Fast reads from summary table only
Key Benefits:
- No resource contention between writes and analytics
- Predictable, fast query performance
- Horizontally scalable with read replicas
- Simple operations - just Postgres + Spring
Trade-offs:
- Data freshness: Up to 1 hour lag (analytics show stats up to the last completed hour)
- Storage overhead: Minimal (~168 rows/room/week)
Endpoint: GET /api/analytics/rooms/{roomId}/stats
Path Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| roomId | string | Yes | The room identifier |
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| startDate | instant | No | Filter from this time (ISO 8601 format) |
| endDate | instant | No | Filter to this time (ISO 8601 format) |
Response: HTTP 200 OK with room statistics aggregated by hour.
Examples:
Get all statistics for a room:
curl http://localhost:8080/api/analytics/rooms/gaming_stream_123/statsGet statistics for a specific date range:
curl "http://localhost:8080/api/analytics/rooms/gaming_stream_123/stats?startDate=2024-01-15T10:00:00Z&endDate=2024-01-15T16:00:00Z"Response Format:
{
"roomId": "gaming_stream_123",
"stats": [
{
"periodStart": "2024-01-15T10:00:00Z",
"periodEnd": "2024-01-15T11:00:00Z",
"totalTips": 25,
"totalAmount": 2500.00,
"uniqueSenders": 12,
"uniqueRecipients": 3,
"averageTipAmount": 100.00
},
{
"periodStart": "2024-01-15T11:00:00Z",
"periodEnd": "2024-01-15T12:00:00Z",
"totalTips": 30,
"totalAmount": 3200.00,
"uniqueSenders": 15,
"uniqueRecipients": 4,
"averageTipAmount": 106.67
}
],
"summary": {
"totalTips": 55,
"totalAmount": 5700.00,
"averageTipAmount": 103.64
}
}Response Fields:
roomId: The room identifierstats: Array of hourly statistics, ordered by period start (ascending)periodStart: Start of the hour (inclusive)periodEnd: End of the hour (exclusive)totalTips: Total number of tips in this hourtotalAmount: Sum of all tip amountsuniqueSenders: Count of distinct sendersuniqueRecipients: Count of distinct recipientsaverageTipAmount: Mean tip amount for this hour
summary: Aggregated statistics across all returned periodstotalTips: Total tips across all periodstotalAmount: Sum across all periodsaverageTipAmount: Overall average (calculated from summary totals)
- Scheduled Job: Every hour at :05 (e.g., 10:05, 11:05), a background job runs
- Single Query: One efficient
GROUP BYquery aggregates all tips from the previous hour across all rooms - Summary Table: Results are stored in the
room_stats_hourlytable - Analytics Queries: The
/api/analyticsendpoint reads ONLY from the summary table, never from thetipstable
Analytics data is aggregated hourly with up to 1 hour lag:
- Tips created at 10:30 will be aggregated at 11:05
- The 10:00-11:00 hourly stats become available at 11:05
This trade-off ensures production-quality performance and scalability.
- Creator Dashboards: Track revenue trends over time
- Performance Analytics: Compare engagement across different time periods
- Audience Insights: Analyze sender and recipient patterns
- Historical Reporting: Generate reports on past engagement
The API is designed to support migration to dedicated analytics databases (ClickHouse, TimescaleDB) without breaking changes:
- API contract remains identical
- Backend implementation swaps data source
- Clients see no difference
TipCurrent provides webhooks for real-time event notifications to external systems. When events occur (e.g., a new tip is created), TipCurrent can automatically send HTTP POST requests to configured webhook endpoints.
- Stream Overlays: Trigger animations when tips are received
- Mobile Apps: Send push notifications for tip events
- Analytics Platforms: Sync tip data to external analytics systems
- Custom Workflows: Build automated responses to tip events (e.g., email notifications, Discord messages)
- Integration: Connect TipCurrent to your existing infrastructure
Tip Created → TipCurrent → Webhook Delivery (async)
↓
Your HTTP Endpoint
Key Features:
- Asynchronous delivery (doesn't block tip creation)
- HMAC signature verification for security
- Automatic retry on failure (1 retry with 5-second delay)
- Delivery logging for debugging
- Per-webhook enable/disable control
Endpoint: POST /api/webhooks
Request Body:
{
"url": "https://your-platform.com/webhooks/tipcurrent",
"event": "tip.created",
"secret": "your-webhook-secret",
"description": "Production webhook for tip notifications"
}Response: HTTP 201 Created
{
"id": 1,
"url": "https://your-platform.com/webhooks/tipcurrent",
"event": "tip.created",
"enabled": true,
"description": "Production webhook for tip notifications",
"createdAt": "2024-01-15T10:00:00Z",
"updatedAt": "2024-01-15T10:00:00Z"
}Endpoint: GET /api/webhooks
Response: HTTP 200 OK
[
{
"id": 1,
"url": "https://your-platform.com/webhooks/tipcurrent",
"event": "tip.created",
"enabled": true,
"description": "Production webhook for tip notifications",
"createdAt": "2024-01-15T10:00:00Z",
"updatedAt": "2024-01-15T10:00:00Z"
}
]Endpoint: GET /api/webhooks/{id}
Response: HTTP 200 OK (same format as create response)
Endpoint: DELETE /api/webhooks/{id}
Response: HTTP 204 No Content
Endpoint: PATCH /api/webhooks/{id}/enable
Response: HTTP 200 OK
Endpoint: PATCH /api/webhooks/{id}/disable
Response: HTTP 200 OK
Endpoint: POST /api/webhooks/{id}/test
Sends a test payload to the webhook URL to verify it's configured correctly.
Response: HTTP 202 Accepted
Endpoint: GET /api/webhooks/{id}/deliveries?page=0&size=20
View delivery history for a specific webhook (success/failure, response codes, etc.).
Response: HTTP 200 OK with paginated delivery logs
When a tip is created, TipCurrent sends an HTTP POST request to your webhook URL:
Headers:
Content-Type: application/json
X-TipCurrent-Signature: <HMAC-SHA256 signature>
X-TipCurrent-Event: tip.created
X-TipCurrent-Delivery-Attempt: 1
Body:
{
"id": 1,
"roomId": "gaming_stream_123",
"senderId": "alice",
"recipientId": "bob",
"amount": 100.00,
"message": "Great play!",
"metadata": "{\"type\":\"celebration\"}",
"createdAt": "2024-01-15T10:30:45.123Z"
}TipCurrent signs webhook payloads using HMAC-SHA256 to ensure authenticity. Your endpoint should verify the signature:
Python Example:
import hmac
import hashlib
import base64
def verify_signature(payload_body, signature_header, webhook_secret):
expected_signature = base64.b64encode(
hmac.new(
webhook_secret.encode('utf-8'),
payload_body.encode('utf-8'),
hashlib.sha256
).digest()
).decode('utf-8')
return hmac.compare_digest(expected_signature, signature_header)
# In your webhook handler:
signature = request.headers.get('X-TipCurrent-Signature')
if not verify_signature(request.body, signature, YOUR_WEBHOOK_SECRET):
return 401 # UnauthorizedNode.js Example:
const crypto = require('crypto');
function verifySignature(payloadBody, signatureHeader, webhookSecret) {
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(payloadBody)
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(signatureHeader)
);
}
// In your webhook handler:
const signature = req.headers['x-tipcurrent-signature'];
if (!verifySignature(req.rawBody, signature, YOUR_WEBHOOK_SECRET)) {
res.status(401).send('Unauthorized');
return;
}Java Example:
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
public boolean verifySignature(String payload, String signature, String secret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKeySpec = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
);
mac.init(secretKeySpec);
byte[] hmacBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
String expectedSignature = Base64.getEncoder().encodeToString(hmacBytes);
return expectedSignature.equals(signature);
} catch (Exception e) {
return false;
}
}Currently supported events:
tip.created- Triggered when a new tip is created
Future events (roadmap):
tip.updatedreaction.createdmilestone.reached
Retry Policy:
- Failed deliveries are automatically retried once after 5 seconds
- HTTP status codes 2xx are considered successful
- All other status codes and network errors trigger retries
Timeouts:
- Connection timeout: 5 seconds
- Request timeout: 10 seconds
Best Practices:
- Respond quickly (< 5 seconds) to avoid timeouts
- Return HTTP 2xx status code to acknowledge receipt
- Process webhook asynchronously on your side (queue for later processing)
- Verify HMAC signatures to ensure authenticity
- Use HTTPS endpoints for security
- Monitor delivery logs to detect issues
1. Register your webhook:
curl -X POST http://localhost:8080/api/webhooks \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-platform.com/webhooks/tipcurrent",
"event": "tip.created",
"secret": "your-secret-key-here",
"description": "Main webhook endpoint"
}'2. Implement your webhook endpoint:
from flask import Flask, request
import hmac
import hashlib
import base64
app = Flask(__name__)
WEBHOOK_SECRET = "your-secret-key-here"
@app.route('/webhooks/tipcurrent', methods=['POST'])
def handle_webhook():
# Verify signature
signature = request.headers.get('X-TipCurrent-Signature')
payload = request.get_data(as_text=True)
expected_sig = base64.b64encode(
hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).digest()
).decode('utf-8')
if not hmac.compare_digest(expected_sig, signature):
return 'Unauthorized', 401
# Process the tip event
data = request.json
print(f"Tip received: {data['senderId']} → {data['recipientId']}: ${data['amount']}")
# Queue for async processing, update database, send notification, etc.
return '', 200
if __name__ == '__main__':
app.run(port=5000)3. Test your webhook:
curl -X POST http://localhost:8080/api/webhooks/1/testCheck your endpoint logs to verify the test payload was received.
Note: Integration tests require Docker to be running for Testcontainers.
On macOS/Linux:
./mvnw testOn Windows:
mvnw.cmd testThe integration tests use Testcontainers to spin up a real PostgreSQL instance, ensuring tests run against the actual database.
You can use the included Docker Compose setup to test manually:
-
Start PostgreSQL:
docker-compose up -d
-
Run the application (macOS/Linux):
./mvnw spring-boot:run
Or on Windows:
mvnw.cmd spring-boot:run
-
Send requests using curl, Postman, or your preferred HTTP client
The tips table stores transactional tip events:
id: Auto-generated primary keyroom_id: Indexed for efficient room-based queriessender_id: User who sent the tiprecipient_id: User who received the tip (indexed)amount: Decimal value with precision 19, scale 2message: Optional text message (up to 1000 characters)metadata: Optional JSON metadatacreated_at: Timestamp, auto-set on creation (indexed)
The room_stats_hourly table stores pre-aggregated analytics:
id: Auto-generated primary keyroom_id: Room identifierperiod_start: Start of hourly period (indexed with room_id)period_end: End of hourly periodtotal_tips: Count of tips in this hourtotal_amount: Sum of tip amounts (precision 19, scale 2)unique_senders: Count of distinct sendersunique_recipients: Count of distinct recipientsaverage_tip_amount: Mean tip amount (precision 19, scale 2)last_aggregated_at: Timestamp when aggregation ran
Indexes:
- Composite index on
(room_id, period_start)for efficient range queries - Index on
period_startfor time-based queries - Unique constraint on
(room_id, period_start)prevents duplicate aggregations
src/
├── main/
│ ├── java/com/mchekin/tipcurrent/
│ │ ├── config/ # WebSocket configuration
│ │ ├── controller/ # REST controllers (Tip, Analytics)
│ │ ├── domain/ # JPA entities (Tip, RoomStatsHourly)
│ │ ├── dto/ # Request/Response DTOs
│ │ ├── repository/ # Spring Data repositories
│ │ ├── scheduler/ # Scheduled jobs (hourly aggregation)
│ │ └── service/ # Business logic (stats aggregation)
│ └── resources/
│ └── application.properties
└── test/
└── java/com/mchekin/tipcurrent/
├── TipIntegrationTest.java
└── AnalyticsIntegrationTest.java
Key configuration in application.properties:
- Database URL, username, password
- JPA/Hibernate settings
- Server port (default: 8080)
For local development, defaults match the Docker Compose configuration.
- Spring Boot 4.0.1
- Java 25
- PostgreSQL 17
- WebSocket with STOMP protocol
- Maven
- Lombok
- Testcontainers
Future iterations may include:
- Aggregation and analytics
- Caching layer
- Authentication and authorization
- Rate limiting
- Multi-region deployment support
MIT License
This is the initial version focusing on the core write path. Contributions should maintain the project's focus on clarity, correctness, and conventional Spring Boot patterns.