Building a Webhook Listener

How to build the endpoint that receives BEEM webhooks — including signature validation, deduplication, and retry behaviour.

A webhook listener is an HTTPS endpoint on your service that BEEM calls whenever an event you have subscribed to fires. This page covers the implementation of the listener itself. You should already have:

  • A hook registered under Integrations → Hooks with the URL of your endpoint (see Hooks)
  • The public key for that hook, available on the hook detail panel (used to verify signatures)
  • A list of the events you want to subscribe to (see Events)

Endpoint requirements

BEEM delivers webhooks as HTTPS POST requests with Content-Type: application/json. Your endpoint must:

RequirementWhy
Accept POST over HTTPSAll webhooks are signed and delivered over TLS.
Respond promptly with a 2xx statusBEEM treats non-2xx responses as failures and retries the delivery until acknowledged.
Tolerate duplicate deliveriesRetries mean the same event may arrive more than once.
Accept JSON of arbitrary depthSome events carry nested objects (transactions, risk, network fee detail).
📘

Respond first, process later

Acknowledge the webhook with 200 OK as soon as the signature is valid, then push the payload onto an internal queue or background job for processing. Doing heavy work synchronously risks timeouts and unnecessary retries.

Signature validation

Every webhook BEEM sends carries an x-signature header — a Base64-encoded RSA signature over the raw request body. Validating this signature confirms the payload came from BEEM and has not been tampered with.

Algorithm: SHA256withRSA (PKCS#1 v1.5 padding) Encoding: Base64 Public key location: Integrations → Hooks → [your hook] → Public Key in the Portal. The key is provided as a Base64-encoded SPKI DER string.

Steps

  1. Read the raw request body before any parsing or normalisation. Whitespace and key ordering matter — verify against the exact bytes BEEM sent.
  2. Read the x-signature header.
  3. Load your hook's public key (Base64 → DER → RSA PublicKey).
  4. Verify x-signature against the raw body using SHA256withRSA.
  5. If verification succeeds, accept the payload. If it fails, return 401 Unauthorized and discard.

Code examples

import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

public class WebhookValidator {

    public static PublicKey loadPublicKey(String publicKeyBase64) throws Exception {
        byte[] decoded = Base64.getDecoder().decode(publicKeyBase64);
        X509EncodedKeySpec spec = new X509EncodedKeySpec(decoded);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        return keyFactory.generatePublic(spec);
    }

    public static boolean verifySignature(String payload, String signatureBase64, PublicKey publicKey) throws Exception {
        byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
        Signature signature = Signature.getInstance("SHA256withRSA");
        signature.initVerify(publicKey);
        signature.update(payload.getBytes());
        return signature.verify(signatureBytes);
    }
}
<?php

function verifyBeemWebhook(string $payload, string $signatureBase64, string $publicKeyBase64): bool
{
    // Convert the Base64-encoded DER (SPKI) public key into PEM format
    $publicKeyDer = base64_decode($publicKeyBase64);
    $publicKeyPem = "-----BEGIN PUBLIC KEY-----\n"
        . chunk_split(base64_encode($publicKeyDer), 64, "\n")
        . "-----END PUBLIC KEY-----\n";

    $publicKey = openssl_pkey_get_public($publicKeyPem);
    if ($publicKey === false) {
        throw new Exception("Failed to load public key: " . openssl_error_string());
    }

    $signature = base64_decode($signatureBase64);
    // OPENSSL_ALGO_SHA256 with an RSA key performs RSASSA-PKCS1-v1_5 verification
    $result = openssl_verify($payload, $signature, $publicKey, OPENSSL_ALGO_SHA256);
    openssl_free_key($publicKey);

    if ($result === 1) return true;
    if ($result === 0) return false;
    throw new Exception("Verification error: " . openssl_error_string());
}
import crypto from "node:crypto";

export function verifyBeemWebhook(rawBody, signatureBase64, publicKeyBase64) {
  const publicKeyDer = Buffer.from(publicKeyBase64, "base64");
  const publicKey = crypto.createPublicKey({
    key: publicKeyDer,
    format: "der",
    type: "spki",
  });

  const verifier = crypto.createVerify("SHA256");
  verifier.update(rawBody);
  verifier.end();

  // createVerify('SHA256') with an RSA key uses RSASSA-PKCS1-v1_5 by default
  return verifier.verify(publicKey, Buffer.from(signatureBase64, "base64"));
}
import base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature

def verify_beem_webhook(raw_body: bytes, signature_base64: str, public_key_base64: str) -> bool:
    public_key_der = base64.b64decode(public_key_base64)
    public_key = serialization.load_der_public_key(public_key_der)

    try:
        public_key.verify(
            base64.b64decode(signature_base64),
            raw_body,
            padding.PKCS1v15(),
            hashes.SHA256(),
        )
        return True
    except InvalidSignature:
        return False
📘

Each webhook has a unique signature

The x-signature header is computed over the exact request body. Reuse of an earlier signature against a different payload will fail verification.

Event deduplication

Every webhook carries an eventId field in the envelope:

{
  "source": "...",
  "event": "layer1:payment:checkout:transaction-confirmed",
  "eventId": "019390f7-83e3-7e01-98d2-c38912094105",
  "timestamp": "2024-12-04T09:19:20.547757183Z",
  "data": { ... }
}

Persist eventId after first successful processing and check it on every subsequent delivery. If you see the same eventId again, acknowledge with 200 OK but skip side effects.

A simple deduplication table:

CREATE TABLE processed_webhooks (
  event_id    UUID PRIMARY KEY,
  event       VARCHAR(255) NOT NULL,
  received_at TIMESTAMP    NOT NULL DEFAULT NOW()
);

Insert with ON CONFLICT DO NOTHING (Postgres) or INSERT IGNORE (MySQL) — the database becomes the source of truth and the application code stays simple.

Retry behaviour

BEEM retries delivery until your endpoint acknowledges the event with a 2xx response. If your listener is offline temporarily, BEEM keeps retrying — once your endpoint comes back and returns 2xx, the backlog continues to be delivered.

Because retries are guaranteed and at-least-once, your deduplication table is the contract that prevents the same event being processed twice.

Reference listener

A minimum-viable BEEM webhook listener has the same shape in every language:

  1. Read the raw request body (do not parse before verifying)
  2. Pull x-signature from the request headers
  3. Verify the signature against the public key
  4. Return 401 Unauthorized if invalid
  5. Parse the JSON and look up the eventId in your dedup store
  6. Return 200 OK immediately
  7. Hand the event off to an async worker for processing
import express from "express";
import { verifyBeemWebhook } from "./verifyBeemWebhook.js";

const app = express();

// Raw body required for signature verification
app.post(
  "/webhooks/beem",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const signature = req.header("x-signature");
    const valid = verifyBeemWebhook(
      req.body, // Buffer
      signature,
      process.env.BEEM_HOOK_PUBLIC_KEY
    );
    if (!valid) return res.status(401).end();

    const event = JSON.parse(req.body.toString("utf8"));

    // Idempotency: skip if eventId already processed
    const inserted = await db.processedWebhooks.insertIgnore(event.eventId);
    if (!inserted) return res.status(200).end();

    // Acknowledge fast, then process async
    res.status(200).end();
    queue.enqueue("beem:webhook", event);
  }
);

app.listen(3000);
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;

@RestController
public class BeemWebhookController {

    private static final String PUBLIC_KEY = System.getenv("BEEM_HOOK_PUBLIC_KEY");
    private final ObjectMapper mapper = new ObjectMapper();

    @PostMapping(value = "/webhooks/beem", consumes = "application/json")
    public ResponseEntity<Void> receive(
            @RequestBody String rawBody,
            @RequestHeader("x-signature") String signature
    ) throws Exception {
        // Verify signature against the raw body
        boolean valid = WebhookValidator.verifySignature(
                rawBody,
                signature,
                WebhookValidator.loadPublicKey(PUBLIC_KEY)
        );
        if (!valid) {
            return ResponseEntity.status(401).build();
        }

        JsonNode event = mapper.readTree(rawBody);
        String eventId = event.get("eventId").asText();

        // Idempotency: skip if eventId already processed
        boolean inserted = processedWebhooks.insertIgnore(eventId);
        if (!inserted) {
            return ResponseEntity.ok().build();
        }

        // Acknowledge fast, then process async
        queue.enqueue("beem:webhook", event);
        return ResponseEntity.ok().build();
    }
}
<?php
require_once "verifyBeemWebhook.php";

// Read the raw body BEFORE any parsing so the signature matches
$rawBody = file_get_contents("php://input");
$signature = $_SERVER["HTTP_X_SIGNATURE"] ?? "";
$publicKey = getenv("BEEM_HOOK_PUBLIC_KEY");

if (!verifyBeemWebhook($rawBody, $signature, $publicKey)) {
    http_response_code(401);
    exit;
}

$event = json_decode($rawBody, true);
$eventId = $event["eventId"] ?? null;

// Idempotency: skip if eventId already processed
$pdo = new PDO("pgsql:host=localhost;dbname=app", "user", "pass");
$stmt = $pdo->prepare(
    "INSERT INTO processed_webhooks (event_id, event) VALUES (:id, :ev) ON CONFLICT DO NOTHING"
);
$stmt->execute([":id" => $eventId, ":ev" => $event["event"]]);

if ($stmt->rowCount() === 0) {
    http_response_code(200);
    exit;
}

// Acknowledge fast, then process async (e.g. enqueue to Redis / SQS)
http_response_code(200);
fastcgi_finish_request(); // flush response, keep working

queueEnqueue("beem:webhook", $event);
import os
import json
from flask import Flask, request, abort
from verify_beem_webhook import verify_beem_webhook

app = Flask(__name__)
PUBLIC_KEY = os.environ["BEEM_HOOK_PUBLIC_KEY"]

@app.post("/webhooks/beem")
def receive():
    raw_body = request.get_data()  # bytes — raw, not parsed
    signature = request.headers.get("x-signature", "")

    if not verify_beem_webhook(raw_body, signature, PUBLIC_KEY):
        abort(401)

    event = json.loads(raw_body)
    event_id = event["eventId"]

    # Idempotency: skip if eventId already processed
    inserted = processed_webhooks.insert_ignore(event_id, event["event"])
    if not inserted:
        return "", 200

    # Acknowledge fast, then process async (e.g. push to Celery / RQ)
    queue.enqueue("beem:webhook", event)
    return "", 200

if __name__ == "__main__":
    app.run(port=3000)

Local testing

To receive webhooks during development, expose your local server on a public URL using a tunneling tool such as ngrok, cloudflared, or your IDE's built-in tunnel. Register the temporary tunnel URL as the hook URL in Integrations → Hooks and use the sandbox to drive events through your listener.

What's next