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. If you enabled Stats Snapshot, pick a format in the Stats Snapshot Format section that appears below Events
  8. 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
Stats SnapshotYour coffee statistics as a whole, refreshed when you log a brew, save a bean, or open the app
Target Weight Reached and Stats Snapshot are not enabled by default on a new endpoint. Switch them on yourself if you want them.

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 */ }
}

Standard and TRMNL

Endpoints that subscribe to Stats Snapshot can choose how that payload is shaped:

FormatBodyUse for
Standard (default)The envelope above, pretty-printed with sorted keysHome Assistant, Discord, Zapier, IFTTT, custom endpoints
TRMNLThe same envelope wrapped in merge_variables, compact and trimmed to fit 2 KBTRMNL private plugins
{
  "merge_variables": {
    "event": "stats_snapshot",
    "timestamp": "2026-01-07T08:30:00Z",
    "data": { /* stats snapshot */ }
  }
}

In a Liquid template the values are addressed as {{ data.totals.brews }}.

The format applies to the stats snapshot only. Every other event keeps the standard envelope, even on a TRMNL endpoint.

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.

Stats Snapshot

Unlike the other events, the stats snapshot describes your statistics as a whole rather than something that just happened. That makes it the payload to point a dashboard at.

{
  "event": "stats_snapshot",
  "timestamp": "2026-01-07T08:30:00Z",
  "data": {
    "updated_at": "2026-01-07T08:30:00Z",
    "totals":    { "brews": 18, "home": 18, "cafe": 0, "ground_g": 324, "avg_dose_g": 18, "brews_per_day": 1.1 },
    "period":    { "days": 30, "brews": 18, "ground_g": 324 },
    "streak":    { "current": 5, "longest": 18 },
    "rating":    { "avg": 4.5, "count": 18 },
    "inventory": { "remaining_g": 750, "frozen_g": 0, "active_beans": 3, "days_left": 39 },
    "variety":   { "beans": 3, "roasters": 3, "origins": 3 },
    "last_brew": {
      "at": "2026-01-07T08:30:00Z",
      "bean": "Ethiopia Yirgacheffe",
      "roaster": "Local Roaster",
      "method": "V60",
      "style": "Filter",
      "rating": 4.5
    },
    "top_beans":     [{ "name": "Ethiopia Yirgacheffe", "roaster": "Local Roaster", "avg_rating": 4.6, "count": 6 }],
    "top_methods":   [{ "name": "V60", "avg_rating": 4.4, "count": 9 }],
    "top_origins":   [{ "name": "Ethiopia", "avg_rating": 4.6, "count": 6 }],
    "top_roasters":  [{ "name": "Local Roaster", "avg_rating": 4.5, "count": 6 }],
    "top_processes": [{ "name": "Washed", "avg_rating": 4.3, "count": 12 }]
  }
}
BlockScopeNotes
totalsLifetimebrews includes cafe visits, avg_dose_g and ground_g are home brews only
periodTrailing 30 daysWindow length is reported in days
streakLifetimeConsecutive brewing days, kept until you miss a full day
ratingLifetimeAverages home and cafe brews that have a rating above 0
inventoryCurrentExcludes archived beans. days_left is null when consumption cannot be estimated
varietyTrailing 30 daysDistinct beans, roasters, and origins brewed
last_brewCurrentNull when you have no brews. style is Filter or Espresso
top_*LifetimeUp to 3 entries each, ranked by average rating. count is the number of rated brews behind it

How the top lists rank

The top_* lists rank by average rating, so they answer “what do I actually enjoy” rather than “what do I reach for most”:

  • Only home brews with a rating above 0 count. Unrated brews are left out entirely rather than counted as a 0, and cafe visits carry no bean or method
  • An entry needs at least 2 rated brews to qualify, so a single lucky 5.0 cannot beat a bean rated 4.8 across forty brews. Entries below that floor are only pulled in when too few qualify to fill the three slots
  • If you never rate your brews, the top_* lists come back empty. The other blocks are unaffected

This deliberately differs from the favourites on the Statistics screen, which rank by number of distinct beans, so the two can show different orders.

Refresh and throttling

Snapshots are sent when you log or edit a brew, save a bean, and when you open or return to the app. Each endpoint receives at most one snapshot every 5 minutes.

Skipping a send costs you nothing, since the next snapshot carries the same information plus whatever happened in between. The throttle applies to this event only, every other event sends immediately.

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

TRMNL

Drives a TRMNL e-ink dashboard from your coffee statistics.

  1. In TRMNL, create a Private Plugin with strategy “Webhook”
  2. Copy the plugin’s webhook URL: https://trmnl.com/api/custom_plugins/{uuid}
  3. In Beanstats, add an endpoint named “TRMNL” and paste that URL
  4. Leave authentication set to None, the UUID in the URL is the secret
  5. Enable the Stats Snapshot event and disable the rest. Other events arrive in the standard envelope, which TRMNL cannot render, and they still count against its hourly rate limit
  6. In the Stats Snapshot Format section below Events, choose TRMNL
  7. Save, then tap Send Test Webhook to fill TRMNL’s “Your Variables” dropdown with a sample snapshot
  8. Build your Liquid template against those variables
<div class="value">{{ data.totals.brews }}</div>
<div class="label">brews all time</div>

<div class="value">{{ data.streak.current }} day streak</div>
<div class="label">longest {{ data.streak.longest }}</div>

<span>{{ data.last_brew.bean }} via {{ data.last_brew.method }}</span>

{% for origin in data.top_origins %}
  <li>{{ origin.name }}: {{ origin.avg_rating }} ({{ origin.count }} rated)</li>
{% endfor %}

TRMNL keeps displaying the last payload it received between sends, so the screen never goes blank.

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

The Stats Snapshot event works well as a dashboard source here too. Keep the format on Standard and read values from trigger.json.data, for example trigger.json.data.inventory.remaining_g for a bean stock sensor.

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