Webhooks

Webhooks

Webhook bei ImproFans erstellen

Melde dich in deinem ImproFans-Account bei den Webhook-Einstellungen (https://improfans.de/u/settings/webhook) an. Hier musst du die Webhook-URL hinzufügen, die im Falle einer Spende von ImproFans aufgerufen werden soll.

⚠️
Deine Webhook-URL muss das HTTPS-Protokoll benutzen. Unverschlüsselte HTTP-Endpunkte werden nicht akzeptiert.

Verwendung

ImproFans ruft deine konfigurierte Webhook-URL auf, sobald du eine Spende erhalten hast.

Dein Webhook wird folgende JSON-Payload von ImproFans empfangen:

{
    "event": "benefit",
    "data": {
        "id": "6bf842c9-a09f-4ccb-8844-83b38dd4786c",
        "amount": 1.23,
        "currency": "EUR",
        "name": "pixlcrashr",
        "message": "This is a donation message.",
        "donor_id": "076ff569-7c58-4205-ae3a-e4fae554eb0a",
        "is_subscription": false,
        "created_at": "2024-12-15T01:04:55+00:00"
    }
}

Hier sind die Felder für das benefit-Event näher beschrieben:

FeldBeschreibungDatentyp
eventTyp des Webhook-Events.String (nur: benefit)
data.idID des Benefits (= Spende).String (UUID)
data.amountBetrag des Benefits.Number
data.currencyWährung des Benefits.String (nur: EUR)
data.nameSpendername des Benefits. Kann null sein, falls kein Name übergeben wurde (z. B. eine anonyme Spende).String, null
data.messageNachricht zur Spende.String
data.donor_idOptionale Spender-ID des Benefits.String (UUID), null
data.is_subscriptionZeigt an, ob diese Spende Teil eines Abonnements ist (WIP).String (UUID), null
data.created_atZeitstempel, wann der Benefit erstellt wurde.String (ISO 8601)
ℹ️
Dein Webhook-Endpunkt/Empfänger muss in der Lage sein, JSON zu parsen (die meisten Plattformen und Programmiersprachen unterstützen von Haus aus das Parsing von JSON).

Wurde der Webhook erfolgreich empfangen, muss ein HTTP 200 OK-Statuscode als Ergebnis zurückgegeben werden. Ein Statuscode, der von 200 OK abweicht, markiert das Webhook-Event als fehlgeschlagen und kann erneut an deinen Endpunkt gesendet werden.

Fortgeschritten: Validierung der Integrität

⚠️
Obwohl wir dringend empfehlen, die Webhook-Payload zu validieren, ist dies je nach Anwendungsfall nicht zwingend erforderlich.

Da dein Webhook öffentlich zugänglich sein muss, damit wir Events senden können, könnten bösartige Akteure ungültige Payloads an deinen Endpunkt senden. Daher signieren wir das HTTP-Payload mittels einer ECDSA-Signatur und stellen diese im HTTP-Header X-Signature bereit.

Beispiel:

X-Signature: B7BE60F73971924D9547C6C8C755866DF9DA56C19AD50B7B571827F6823D8434EA46AE1D4FADFBE4E46A9518747817A0F63FE6D4E59503982E07BDEBC8788C22

Dies ist unser öffentlicher Schlüssel – ein EC (Elliptic Curves Public Key):

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWZrLPi5uAvrLKQ5mgWLXICH3bkk2
oXl2WZLGwKysLdYPdsyc7Dc3UOPPFPdQH5Mjp24kDjiCVztCeNYc/PqR7Q==
-----END PUBLIC KEY-----

Beispiel

Hier ein kurzes Beispiel, wie du in einer Programmiersprache die Signatur einer Webhook-Anfrage überprüfen kannst:

package main

import (
    "crypto/ecdsa"
    "crypto/sha256"
    "crypto/x509"
    "encoding/hex"
    "encoding/pem"
    "io"
    "log"
    "math/big"
    "net/http"
)

const publicKeyPEM = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWZrLPi5uAvrLKQ5mgWLXICH3bkk2
oXl2WZLGwKysLdYPdsyc7Dc3UOPPFPdQH5Mjp24kDjiCVztCeNYc/PqR7Q==
-----END PUBLIC KEY-----`

var publicKey *ecdsa.PublicKey

func main() {
    // Parse the public key once at startup
    var err error
    publicKey, err = parsePublicKey(publicKeyPEM)
    if err != nil {
        log.Fatalf("Failed to parse public key: %v", err)
    }

    http.HandleFunc("/webhook", handleWebhook)
    
    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    // Only accept POST requests
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    // Read the signature from header
    signature := r.Header.Get("X-Signature")
    if signature == "" {
        http.Error(w, "Missing signature", http.StatusBadRequest)
        return
    }

    // Read the request body
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Failed to read request body", http.StatusBadRequest)
        return
    }
    defer r.Body.Close()

    // Verify the signature
    valid, err := verifySignature(body, signature)
    if err != nil {
        log.Printf("Signature verification error: %v", err)
        http.Error(w, "Invalid signature format", http.StatusBadRequest)
        return
    }

    if !valid {
        log.Printf("Invalid signature for payload: %s", string(body))
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    // Signature is valid, log the payload
    log.Printf("Received valid webhook: %s", string(body))
    w.WriteHeader(http.StatusOK)
}

func verifySignature(message []byte, signatureHex string) (bool, error) {
    // Decode the hex signature
    signature, err := hex.DecodeString(signatureHex)
    if err != nil {
        return false, err
    }

    // Signature should be exactly 64 bytes (r and s are 32 bytes each)
    if len(signature) != 64 {
        return false, err
    }

    // Split signature into r and s
    r := new(big.Int).SetBytes(signature[:32])
    s := new(big.Int).SetBytes(signature[32:])

    // Calculate the hash of the message
    hash := sha256.Sum256(message)

    // Verify the signature
    return ecdsa.Verify(publicKey, hash[:], r, s), nil
}

func parsePublicKey(pemKey string) (*ecdsa.PublicKey, error) {
    block, _ := pem.Decode([]byte(pemKey))
    if block == nil {
        return nil, err
    }

    pub, err := x509.ParsePKIXPublicKey(block.Bytes)
    if err != nil {
        return nil, err
    }

    ecdsaPub, ok := pub.(*ecdsa.PublicKey)
    if !ok {
        return nil, err
    }

    return ecdsaPub, nil
}
const express = require('express');
const crypto = require('crypto');

const app = express();

// Configure Express to get raw body for verification
app.use(express.json({
    verify: (req, res, buf) => {
        // Store raw body for signature verification
        req.rawBody = buf;
    }
}));

// The public key used for verification
const publicKeyPEM = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWZrLPi5uAvrLKQ5mgWLXICH3bkk2
oXl2WZLGwKysLdYPdsyc7Dc3UOPPFPdQH5Mjp24kDjiCVztCeNYc/PqR7Q==
-----END PUBLIC KEY-----`;

function verifySignature(message, signatureHex) {
    try {
        // Convert hex signature to buffer
        const signature = Buffer.from(signatureHex, 'hex');
        
        // Signature should be exactly 64 bytes (r and s are 32 bytes each)
        if (signature.length !== 64) {
            throw new Error('Invalid signature length');
        }

        // Split signature into r and s components
        const r = signature.slice(0, 32);
        const s = signature.slice(32);

        // Create verifier
        const verifier = crypto.createVerify('SHA256');
        verifier.update(message);

        // Verify the signature (using raw/ieee-p1363 format)
        return verifier.verify({ key: publicKeyPEM, dsaEncoding: 'ieee-p1363' }, Buffer.concat([r, s]));
    } catch (error) {
        console.error('Signature verification error:', error);
        return false;
    }
}

app.post('/webhook', (req, res) => {
    try {
        // Get signature from header
        const signature = req.headers['x-signature'];
        if (!signature) {
            console.log('Missing signature header');
            return res.status(400).json({ error: 'Missing signature' });
        }

        // Verify signature using raw body
        const isValid = verifySignature(req.rawBody, signature);
        
        if (!isValid) {
            console.log('Invalid signature for payload:', req.body);
            return res.status(401).json({ error: 'Invalid signature' });
        }

        // Log the valid webhook
        console.log('Received valid webhook:', req.body);
        
        // Send success response
        res.status(200).json({ status: 'success' });
    } catch (error) {
        console.error('Webhook processing error:', error);
        res.status(500).json({ error: 'Internal server error' });
    }
});

// Add basic error handling
app.use((err, req, res, next) => {
    console.error('Unhandled error:', err);
    res.status(500).json({ error: 'Internal server error' });
});

// Start the server
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
    console.log(`Server is running on port ${PORT}`);
});