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.

Webhooks require a Premium subscription, or Premium Lifetime.

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

  1. Navigate to Settings > Data & Storage > Webhooks
  2. Tap Add Endpoint
  3. Enter a name (e.g., “Discord”)
  4. Enter the webhook URL
  5. Configure authentication if needed
  6. Select which events should trigger this webhook
  7. Tap Save

Supported Events

EventTrigger
Brew LoggedWhen you log a new home brew
Bean AddedWhen you add a new coffee bean
Bean Running LowWhen a bean’s weight drops below threshold
Freeze Entry CreatedWhen you freeze a coffee portion
Freeze Entry ThawedWhen you thaw frozen coffee
Target Weight ReachedDuring espresso when weight reaches target minus drip-through offset

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 sent
  • X-Webhook-Signature: HMAC-SHA256 signature prefixed with sha256=

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.

Drip-Through Offset

Configure the grams to subtract from the target weight for the “target weight reached” event (default: 2.0g). This accounts for liquid still in transit from the group head to the cup after the pump stops. The webhook fires when currentWeight >= targetWeight - dripThroughOffset, allowing automation systems to stop the pump slightly early so the final yield lands on target.

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:

  1. Find the failed attempt in Recent Activity
  2. Tap the retry button
  3. 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 suffixUnit
*GramsGrams (g)
*CelsiusCelsius (°C)
*SecondsSeconds (s)
*BarsBar pressure

Entity IDs

Entity IDs (id, beanId) in webhook payloads are standard UUID strings. These IDs:

  • Are stable across app launches, reinstalls, and iCloud sync
  • Are human-readable standard UUID format
  • Can be used in deep links (e.g., beanstats://brew/{uuid}/rate)

Example: 550e8400-e29b-41d4-a716-446655440000

Example Payloads

Brew Logged:

{
  "event": "brew_logged",
  "timestamp": "2026-01-07T08:30:00Z",
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "beanId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "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": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "name": "Colombia Huila",
    "roaster": "Acme Roasters",
    "roastLevel": "Medium",
    "roastDate": "2026-01-01T00:00:00Z",
    "tastingNotes": "Caramel, Red Apple",
    "varieties": [
      {
        "name": "Castillo",
        "originCountry": "Colombia",
        "originRegion": "Huila",
        "processMethod": "Washed"
      }
    ]
  }
}

Bean Running Low:

{
  "event": "bean_running_low",
  "timestamp": "2026-01-07T08:30:00Z",
  "data": {
    "beanId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "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": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "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": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "beanName": "Ethiopia Yirgacheffe",
    "roaster": "Local Roaster",
    "thawedGrams": 100,
    "thawedAt": "2026-01-07T08:30:00Z",
    "tag": "7HK3N",
    "deeplink": "beanstats://freeze/7HK3N"
  }
}

Target Weight Reached:

{
  "event": "target_weight_reached",
  "timestamp": "2026-01-07T08:30:00Z",
  "data": {
    "currentWeight": 34.5,
    "targetWeight": 36.0,
    "offsetGrams": 2.0,
    "elapsedSeconds": 25.3,
    "flowRate": 1.8
  }
}
FieldDescription
currentWeightWeight in grams when the threshold was crossed
targetWeightUser-configured target yield weight in grams
offsetGramsDrip-through offset that was applied
elapsedSecondsSeconds elapsed since brew start
flowRateFlow rate in g/s at the moment of triggering
This event fires once per brew when currentWeight >= targetWeight - offsetGrams. It is espresso-only and requires a “Brew by weight target” value to be set in the brew form’s Yield section.

Integration Examples

Discord

  1. Create a webhook in your Discord server settings
  2. Add endpoint with the Discord webhook URL
  3. Set authentication to None
  4. Select desired events

Home Assistant

  1. Create a webhook automation in Home Assistant
  2. Set URL to: http://homeassistant.local:8123/api/webhook/{webhook_id}
  3. Use Bearer Token authentication with a long-lived access token
  4. Enable Allow Insecure Connection for local HTTP
  5. Create automations based on incoming payloads

IFTTT

  1. Create an IFTTT Webhooks applet
  2. Use your IFTTT webhook URL: https://maker.ifttt.com/trigger/{event}/with/key/{key}
  3. Set authentication to None

Zapier

  1. Create a Zap with “Webhooks by Zapier” trigger
  2. Choose “Catch Hook”
  3. Copy the webhook URL from Zapier
  4. Zapier parses the JSON and can connect to thousands of apps