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:
| Requirement | Why |
|---|---|
Accept POST over HTTPS | All webhooks are signed and delivered over TLS. |
Respond promptly with a 2xx status | BEEM treats non-2xx responses as failures and retries the delivery until acknowledged. |
| Tolerate duplicate deliveries | Retries mean the same event may arrive more than once. |
| Accept JSON of arbitrary depth | Some events carry nested objects (transactions, risk, network fee detail). |
Respond first, process laterAcknowledge the webhook with
200 OKas 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
- Read the raw request body before any parsing or normalisation. Whitespace and key ordering matter — verify against the exact bytes BEEM sent.
- Read the
x-signatureheader. - Load your hook's public key (Base64 → DER → RSA
PublicKey). - Verify
x-signatureagainst the raw body usingSHA256withRSA. - If verification succeeds, accept the payload. If it fails, return
401 Unauthorizedand 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 signatureThe
x-signatureheader 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:
- Read the raw request body (do not parse before verifying)
- Pull
x-signaturefrom the request headers - Verify the signature against the public key
- Return
401 Unauthorizedif invalid - Parse the JSON and look up the
eventIdin your dedup store - Return
200 OKimmediately - 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.

