Webhooks
The Webhooks feature enables Beanstats to send notifications to external services when events occur in the app. This allows integration with Discord, Home Assistant, IFTTT, Zapier, and custom endpoints.
Overview
Webhooks send JSON payloads to user-configured URLs when specific events occur:
- Multiple endpoints: Configure different webhooks for each service
- Per-endpoint events: Choose which events trigger each webhook
- Attempt history: View recent webhook attempts with success/failure status
- Retry failed requests: One-tap retry for failed webhooks
Setting Up Webhooks
- Navigate to Settings > Data & Storage > Webhooks
- Tap Add Endpoint
- Enter a name (e.g., “Discord”)
- Enter the webhook URL
- Configure authentication if needed
- Select which events should trigger this webhook
- Tap Save
Supported Events
| Event | Trigger |
|---|---|
| Brew Logged | When you log a new home brew |
| Bean Added | When you add a new coffee bean |
| Bean Running Low | When a bean’s weight drops below threshold |
| Freeze Entry Created | When you freeze a coffee portion |
| Freeze Entry Thawed | When you thaw frozen coffee |
Authentication Methods
None
No authentication headers are sent. Use this for services that don’t require authentication (like Discord webhooks).
Bearer Token
Adds an Authorization: Bearer <token> header to requests.
API Key Header
Adds a custom header with your API key:
- Configure the header name (default:
X-API-Key) - Enter your API key value
Request Signing (HMAC-SHA256)
For enhanced security, enable HMAC-SHA256 signing. This adds two headers:
X-Webhook-Timestamp: Unix timestamp when the request was sentX-Webhook-Signature: HMAC-SHA256 signature prefixed withsha256=
Verifying Signatures
The signature is computed over: {timestamp}.{json_payload}
Node.js Example:
const crypto = require('crypto');
function verifyWebhook(req, rawBody, secret) {
const timestamp = req.headers['x-webhook-timestamp'];
const signature = req.headers['x-webhook-signature'];
if (!timestamp || !signature) return false;
// Check timestamp is recent (within 5 minutes)
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
if (age > 300) return false;
// Compute expected signature using raw body
const payload = `${timestamp}.${rawBody}`;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Express middleware to capture raw body:
// app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf.toString(); } }));
Python Example:
import hmac
import hashlib
import time
def verify_webhook(headers, raw_body, secret):
# Header names may vary by framework (lowercase for Flask)
timestamp = headers.get('X-Webhook-Timestamp') or headers.get('x-webhook-timestamp')
signature = headers.get('X-Webhook-Signature') or headers.get('x-webhook-signature')
if not timestamp or not signature:
return False
# Check timestamp is recent (within 5 minutes)
if abs(time.time() - int(timestamp)) > 300:
return False
# Compute expected signature using raw body string
payload = f"{timestamp}.{raw_body}"
expected = 'sha256=' + hmac.new(
secret.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)Go Example:
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"math"
"net/http"
"strconv"
"time"
)
func verifyWebhook(r *http.Request, secret string) ([]byte, bool) {
timestamp := r.Header.Get("X-Webhook-Timestamp")
signature := r.Header.Get("X-Webhook-Signature")
if timestamp == "" || signature == "" {
return nil, false
}
// Check timestamp is recent (within 5 minutes)
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return nil, false
}
age := math.Abs(float64(time.Now().Unix() - ts))
if age > 300 {
return nil, false
}
// Read raw body
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, false
}
// Compute expected signature
payload := fmt.Sprintf("%s.%s", timestamp, string(body))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
// Constant-time comparison
if !hmac.Equal([]byte(signature), []byte(expected)) {
return nil, false
}
return body, true
}Advanced Options
Allow Insecure Connection
Enable this for endpoints using:
- Self-signed SSL certificates
- HTTP URLs (non-HTTPS)
- Local network services (e.g., Home Assistant on
http://homeassistant.local:8123)
Low Stock Threshold
Configure the weight threshold (in grams) for the “bean running low” event. Default is 50g.
Attempt History
View recent webhook activity in the settings:
- Status: Success (green), Failed (red), or Pending (spinner)
- Endpoint name: Which webhook was called
- Event type: What triggered it
- Timestamp: When the attempt was made
- Error details: Information for failed requests
Retrying Failed Webhooks
Failed attempts can be retried:
- Find the failed attempt in Recent Activity
- Tap the retry button
- The webhook resends with the original payload
Payload Format
All webhooks send JSON with this structure:
{
"event": "brew_logged",
"timestamp": "2026-01-07T08:30:00Z",
"data": { /* event-specific data */ }
}Units
All values use consistent units regardless of your display preferences:
| Field suffix | Unit |
|---|---|
*Grams | Grams (g) |
*Celsius | Celsius (°C) |
*Seconds | Seconds (s) |
*Bars | Bar pressure |
Entity IDs
Entity IDs (like id, beanId) are stable, base64-encoded identifiers. These IDs:
- Are stable across app launches and reinstalls
- Can be used in URL schemes (e.g.,
beanstats://brew/{id}) - Are URL-safe
Example Payloads
Brew Logged:
{
"event": "brew_logged",
"timestamp": "2026-01-07T08:30:00Z",
"data": {
"id": "eyJpbXBsZW1lbnRhdGlvbiI6eyJwcmlt...",
"beanId": "eyJpbXBsZW1lbnRhdGlvbiI6eyJwcmlt...",
"timestamp": "2026-01-07T08:30:00Z",
"bean": {
"name": "Ethiopia Yirgacheffe",
"roaster": "Local Roaster",
"roastLevel": "Light"
},
"recipe": {
"style": "espresso",
"doseGrams": 18.0,
"yieldGrams": 36.0,
"brewTimeSeconds": 28
},
"evaluation": {
"rating": 4.5,
"notes": "Great shot!"
}
}
}Bean Added:
{
"event": "bean_added",
"timestamp": "2026-01-07T08:30:00Z",
"data": {
"id": "eyJpbXBsZW1lbnRhdGlvbiI6eyJwcmlt...",
"name": "Colombia Huila",
"roaster": "Acme Roasters",
"roastLevel": "Medium",
"roastDate": "2026-01-01",
"varieties": [
{
"originCountry": "Colombia",
"originRegion": "Huila",
"processMethod": "Washed"
}
]
}
}Bean Running Low:
{
"event": "bean_running_low",
"timestamp": "2026-01-07T08:30:00Z",
"data": {
"beanId": "eyJpbXBsZW1lbnRhdGlvbiI6eyJwcmlt...",
"beanName": "Ethiopia Yirgacheffe",
"roaster": "Local Roaster",
"remainingGrams": 42,
"thresholdGrams": 50
}
}Freeze Entry Created:
{
"event": "freeze_entry_created",
"timestamp": "2026-01-07T08:30:00Z",
"data": {
"beanId": "eyJpbXBsZW1lbnRhdGlvbiI6eyJwcmlt...",
"beanName": "Ethiopia Yirgacheffe",
"roaster": "Local Roaster",
"frozenGrams": 100,
"containerType": "Vacuum Bag",
"tag": "7HK3N",
"freezeDate": "2026-01-07T08:30:00Z",
"deeplink": "beanstats://freeze/7HK3N"
}
}Freeze Entry Thawed:
{
"event": "freeze_entry_thawed",
"timestamp": "2026-01-07T08:30:00Z",
"data": {
"beanId": "eyJpbXBsZW1lbnRhdGlvbiI6eyJwcmlt...",
"beanName": "Ethiopia Yirgacheffe",
"roaster": "Local Roaster",
"thawedGrams": 100,
"thawedAt": "2026-01-07T08:30:00Z",
"tag": "7HK3N",
"deeplink": "beanstats://freeze/7HK3N"
}
}Integration Examples
Discord
- Create a webhook in your Discord server settings
- Add endpoint with the Discord webhook URL
- Set authentication to None
- Select desired events
Home Assistant
- Create a webhook automation in Home Assistant
- Set URL to:
http://homeassistant.local:8123/api/webhook/{webhook_id} - Use Bearer Token authentication with a long-lived access token
- Enable Allow Insecure Connection for local HTTP
- Create automations based on incoming payloads
IFTTT
- Create an IFTTT Webhooks applet
- Use your IFTTT webhook URL:
https://maker.ifttt.com/trigger/{event}/with/key/{key} - Set authentication to None
Zapier
- Create a Zap with “Webhooks by Zapier” trigger
- Choose “Catch Hook”
- Copy the webhook URL from Zapier
- Zapier parses the JSON and can connect to thousands of apps