Pro / Enterprise

Webhooks

Receive real-time notifications when subscribers join, update their preferences, or unsubscribe from your calendar feeds.

Overview

Webhooks let you receive HTTP POST requests to your server whenever subscriber-related events happen on your EmbedACal feeds. This is useful for syncing subscriber data to your CRM, email marketing tools, or custom systems.

Each webhook delivery is signed with HMAC-SHA256, so you can verify that the request genuinely came from EmbedACal.

Setting up webhooks

  1. Go to your dashboard and click "Webhooks" in the sidebar
  2. Click "Add endpoint"
  3. Enter your HTTPS endpoint URL
  4. Select which events you want to receive
  5. Copy the signing secret (shown once — store it securely in your server's environment variables)

Your endpoint must respond with a 2xx status code within 10 seconds to be considered successful.

Event types

EventDescription
subscriber.createdA new subscriber confirmed their email and is now active
subscriber.updatedA subscriber changed their notification frequency or criteria
subscriber.unsubscribedA subscriber opted out and was removed

Payload format

All webhook payloads are JSON with the following structure:

subscriber.created

POST /your-webhook-url

{
  "event": "subscriber.created",
  "data": {
    "email": "[email protected]",
    "feedSlug": "community-events",
    "criteria": null,
    "digestFrequency": "daily",
    "timestamp": 1740000000000
  },
  "timestamp": 1740000000000
}

subscriber.updated

POST /your-webhook-url

{
  "event": "subscriber.updated",
  "data": {
    "email": "[email protected]",
    "feedSlug": "community-events",
    "criteria": "{\"keywords\":\"outdoor\",\"state\":\"California\"}",
    "digestFrequency": "weekly",
    "timestamp": 1740100000000
  },
  "timestamp": 1740100000000
}

subscriber.unsubscribed

POST /your-webhook-url

{
  "event": "subscriber.unsubscribed",
  "data": {
    "email": "[email protected]",
    "feedSlug": "community-events",
    "timestamp": 1740200000000
  },
  "timestamp": 1740200000000
}

Verifying signatures

Every webhook request includes an X-EmbedACal-Signature header containing an HMAC-SHA256 hex digest of the request body, signed with your endpoint's secret.

Always verify this signature before processing the webhook to ensure the request is authentic.

Node.js / JavaScript

verify-webhook.js

import crypto from 'crypto';

function verifyWebhook(body, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// In your webhook handler:
app.post('/webhooks/embedacal', (req, res) => {
  const signature = req.headers['x-embedacal-signature'];
  const isValid = verifyWebhook(
    JSON.stringify(req.body),
    signature,
    process.env.EMBEDACAL_WEBHOOK_SECRET
  );

  if (!isValid) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const { event, data } = req.body;
  // Process the webhook event...

  res.status(200).json({ received: true });
});

Python

verify_webhook.py

import hmac
import hashlib

def verify_webhook(body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        body,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)

# In your webhook handler (Flask example):
@app.route('/webhooks/embedacal', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-EmbedACal-Signature', '')
    is_valid = verify_webhook(
        request.data,
        signature,
        os.environ['EMBEDACAL_WEBHOOK_SECRET']
    )

    if not is_valid:
        return {'error': 'Invalid signature'}, 401

    payload = request.json
    event_type = payload['event']
    data = payload['data']
    # Process the webhook event...

    return {'received': True}, 200

Ruby

verify_webhook.rb

require 'openssl'

def verify_webhook(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, body)
  Rack::Utils.secure_compare(signature, expected)
end

# In your webhook handler (Sinatra example):
post '/webhooks/embedacal' do
  body = request.body.read
  signature = request.env['HTTP_X_EMBEDACAL_SIGNATURE']

  unless verify_webhook(body, signature, ENV['EMBEDACAL_WEBHOOK_SECRET'])
    halt 401, { error: 'Invalid signature' }.to_json
  end

  payload = JSON.parse(body)
  # Process the webhook event...

  { received: true }.to_json
end

curl (testing)

Test your endpoint

# Generate a test signature
SECRET="whsec_your_secret_here"
BODY='{"event":"subscriber.created","data":{"email":"[email protected]","feedSlug":"my-feed","timestamp":1740000000000},"timestamp":1740000000000}'

SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

# Send test webhook
curl -X POST https://your-app.com/webhooks/embedacal \
  -H "Content-Type: application/json" \
  -H "X-EmbedACal-Signature: $SIGNATURE" \
  -d "$BODY"

Retries and failure handling

If your endpoint doesn't return a 2xx status code within 10 seconds, EmbedACal will retry the delivery with exponential backoff:

  • First retry: 1 minute after initial failure
  • Second retry: 5 minutes after first retry
  • Third retry: 30 minutes after second retry

After 3 failed retries, the delivery is marked as failed. You can see delivery status in your dashboard under Webhooks.

Your endpoint should be idempotent — the same event may be delivered more than once in edge cases. Use the timestamp field to deduplicate.

Security best practices

  • Always verify the X-EmbedACal-Signature header before processing any webhook
  • Use HTTPS for your endpoint URL (HTTP endpoints are rejected)
  • Store the signing secret in environment variables, never in code
  • Respond quickly (within 10 seconds) — do heavy processing asynchronously after acknowledging receipt
  • If your secret is compromised, regenerate it immediately from the dashboard (the old secret stops working right away)
  • Use a constant-time comparison function when verifying signatures to prevent timing attacks