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.

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

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.

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 (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

  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