Skip to content

Interop recipes

These recipes assume you have already read Webhooks: the verification handshake, the barakah.http-message-signatures.v2 profile, the legacy Standard Webhooks profile, and identity/retry/replay semantics. They are vendor-neutral by design: no Barakah connector or SDK exists for any of these platforms, and none is coming. Everything below is a plain HTTP receiver plus the same signature verification you would write for any other webhook.

The Webhook node parses JSON by default, which discards the exact bytes the signature was computed over. Binary mode is required for verification; use structured mode (application/cloudevents+json) only until the raw-body path is confirmed working, per Webhooks.

  1. Webhook node - set Response Mode to “Using Respond to Webhook Node” (so verification confirmation can be synchronous) and, critically, enable Raw Body (or Binary Data, depending on your n8n version) so $binary.data or the raw body property holds the untouched bytes. Do not let n8n auto-parse the body as JSON before your Code node runs.

  2. Code node - import the same verifier the portal publishes (verify-webhook-v2.mjs) and call it with the raw bytes, not $json:

    import { verifyBarakahWebhook } from './verify-webhook-v2.mjs';
    const rawBody = Buffer.from($binary.data.data, 'base64'); // exact bytes n8n received
    const headers = $request.headers; // n8n lower-cases header names already
    const verified = verifyBarakahWebhook({
    rawBody,
    requestHeaders: headers,
    expectedSubscriptionId: 'YOUR_SUBSCRIPTION_ID',
    pinnedPublicKey: 'whpk_...', // from get_webhook_subscription, never from the callback
    });
    return [{ json: verified.data }];

    n8n’s Code node environment does not always support ES module import - if yours does not, inline the verifier’s logic or load it with require() after converting it to CommonJS; do not skip verification.

  3. Only after verifyBarakahWebhook returns without throwing should the workflow act on verified.data. A thrown error means reject the request; do not swallow it and continue.

A 2xx response from the Webhook node only proves the HTTP request was received. It does not prove that a downstream node (Gmail, Sheets, a database write) actually completed - see the receiver checklist in Webhooks. Make the workflow durably persist or complete its own side effect before you consider the delivery handled.

Generic Ed25519 verification (Node.js and Python)

Section titled “Generic Ed25519 verification (Node.js and Python)”

Use these when the platform is not n8n/Activepieces/Make/Zapier - any framework that hands you a raw request body and headers. The header names below are the exact ones the platform sends and the ones verify-webhook-v2.mjs checks: signature-input, signature, content-digest, webhook-id, webhook-timestamp, barakah-subscription-id, barakah-key-id, plus the message-kind-specific identity headers (barakah-event-id/barakah-delivery-id/barakah-attempt-id/ barakah-replay-generation for events, barakah-probe-id for verification callbacks). Do not invent alternate names - see Covered components for the full ordered list per wire shape.

Prefer the published verifier directly:

import { verifyBarakahWebhook } from './verify-webhook-v2.mjs';
const verified = verifyBarakahWebhook({
rawBody, // Buffer/Uint8Array of the untouched request body
requestHeaders: req.headers, // lower-cased header map
expectedSubscriptionId: subscriptionId,
pinnedPublicKey: currentPublicKey, // whpk_... from get_webhook_subscription
});

If you need to verify the raw Ed25519 signature yourself (for example inside a platform that cannot load a local module), the primitive is crypto.verify(null, message, publicKey, signature) (Node’s built-in node:crypto, Ed25519 needs no digest algorithm argument) against the @signature-params-terminated message described in HTTP Message Signatures v2. Do not reimplement the covered-component ordering or clock-window checks from scratch - copy them from the published verifier; they are the part most likely to be gotten subtly wrong.

import base64
import hashlib
import hmac
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
def verify_signature(raw_body: bytes, signature_base: str, signature_b64: str, public_key_whpk: str) -> bool:
# public_key_whpk is "whpk_" + standard base64 of the raw 32-byte key
raw_key = base64.b64decode(public_key_whpk.removeprefix("whpk_"))
public_key = Ed25519PublicKey.from_public_bytes(raw_key)
signature = base64.b64decode(signature_b64)
try:
public_key.verify(signature, signature_base.encode("utf-8"))
return True
except Exception:
return False
def content_digest(raw_body: bytes) -> str:
return "sha-256=:" + base64.b64encode(hashlib.sha256(raw_body).digest()).decode() + ":"

Build signature_base from the exact ordered covered components for the message’s content type and kind (see Covered components), each line formatted as "<component>": <value> (lower-case component name, @method value is the literal string POST), terminated by a "@signature-params": ... line copied verbatim from the parsed Signature-Input value for the label you are checking. Verify content-digest against content_digest(raw_body) before trusting anything else. Reject on any mismatch - do not fall back to a partial or best-effort check.

get_webhook_subscription returns the current whpk_ public key and, during an active rotation window, the previous one. Pin both when present, and accept whichever member (barakah-current or barakah-previous) of the Signature-Input/Signature dictionaries matches. Never trust a key that only appears in the callback itself - see Legacy Standard Webhooks signature and HTTP Message Signatures v2 for the full rotation contract.

Platforms in this class expose one generic “run JavaScript” step against the raw webhook payload. The template below is a single function you paste in and adapt to the platform’s exact input/output binding:

function verifyAndUnwrap(input) {
const { verifyBarakahWebhook } = require('./verify-webhook-v2.mjs'); // or inline the logic
const rawBody = Buffer.from(input.rawBody, 'base64'); // however the platform hands you exact bytes
const headers = input.headers; // must be the exact, lower-cased header set the platform received
const verified = verifyBarakahWebhook({
rawBody,
requestHeaders: headers,
expectedSubscriptionId: input.subscriptionId,
pinnedPublicKey: input.pinnedPublicKey,
});
return { data: verified.data, eventId: verified.eventId, messageKind: verified.messageKind };
}

The reminder that matters here: many of these platforms re-serialize the incoming JSON body before your code step ever sees it (their trigger normalizes the payload for the visual editor). A re-serialized body is not the signed body - key order, whitespace, and numeric formatting differences all break the Ed25519 check even though the content looks identical. Before relying on one of these platforms in production, confirm its trigger step exposes the untouched raw bytes (often a “raw body” or “advanced” trigger option, sometimes only available on a paid tier); if it does not, use structured mode and still verify against whatever raw bytes the platform does expose, and treat any platform that offers neither as unable to verify signatures at all - do not silently skip verification to keep the workflow running.

Activepieces: same mechanism, raw body already exposed

Section titled “Activepieces: same mechanism, raw body already exposed”

Activepieces is the one platform in this class whose trigger hands you the exact bytes without any extra option. Its Catch Webhook trigger emits the whole request it received as the trigger output: method, headers (all lower-cased), body (parsed JSON), queryParams, and rawBody, the untouched request body as a string. rawBody is the same field the trigger’s own HMAC option hashes, so it is the signed bytes, not a re-serialization.

Two consequences:

  1. Leave the trigger’s Authentication on “None”. The built-in options (Basic, Header, HMAC) cannot check an Ed25519 signature, and a mismatched setting silently drops every request without starting a run - you would see nothing, not an error.
  2. Verify in a Code step using the template above. Map the step inputs to {{trigger['output'].rawBody}} and {{trigger['output'].headers}}, and feed the signature check the raw string as UTF-8 bytes:
export const code = async (inputs) => {
const rawBody = Buffer.from(inputs.rawBody, 'utf8'); // never JSON.stringify(inputs.body)
const headers = inputs.headers; // already lower-cased by Activepieces
const verified = verifyBarakahWebhook({
rawBody,
requestHeaders: headers,
expectedSubscriptionId: inputs.subscriptionId,
pinnedPublicKey: inputs.pinnedPublicKey,
});
return { data: verified.data, eventId: verified.eventId, messageKind: verified.messageKind };
};

Verification handshake. Barakah’s verification message arrives at the same URL as events. Give the subscription the trigger’s /sync URL variant, so the flow’s response reaches Barakah, and answer the verification message with a Return Response step (JSON, status 200) whose body is the trigger’s body - {{trigger['output'].body}} is the closed verification object Barakah expects back. Branch on messageKind from the Code step (or on the presence of challenge in the body) so event deliveries do not get the same echo. The /test URL variant only generates sample data and never runs the published flow, which makes it a safe way to inspect the raw payload shape before wiring the check.

Loop safety is unchanged: a Code step that writes back through MCP should send a correlation_id and drop events whose barakahoriginconnection is its own connection (see below).

replay_webhook_events (MCP, webhooks:write plus events:read) is the supported way to exercise a receiver end to end without waiting for a real event to occur naturally: point it at exact event IDs (from list_integration_events, get_integration_event, or list_webhook_deliveries) or an open gap ID (from list_webhook_gaps), and it queues a fresh delivery attempt against your current endpoint.

The repair-then-replay order matters. If the receiver was ever unreachable, misconfigured, or paused, fix the endpoint (rotate keys, redeploy, resolve receiver_gone/verification failures) and confirm the subscription is active again before replaying - automatic retries would have already exhausted the 72-hour window for anything not covered by an open gap, so a stale endpoint will just fail the replay the same way it failed the original delivery.

Idempotency: a replay is a genuinely new delivery, even when it carries the same CloudEvent id/webhook-id as the original. It gets a new barakah-delivery-id and a new barakah-replay-generation; only the underlying event identity and, for a subscription-scoped read, the barakahsubscriptionsequence stay the same. Practically:

  • Deduplicate automatic HTTP retries by the stable barakah-delivery-id (retries of the same delivery keep that ID; only barakah-attempt-id, webhook-timestamp, and the signature change).
  • Never deduplicate an explicit replay by CloudEvent ID alone - a receiver that does will silently ignore a replay you triggered on purpose. If your receiver needs replay to be a genuine no-op re-run (rather than executing side effects twice), key its own idempotency ledger off the new barakah-delivery-id/barakah-replay-generation, not the event ID.
  • A receiver that intentionally wants to re-execute on replay (the common case when replay is being used as a deliberate test fire, or to recover a gap by re-running the original side effect) needs no special handling beyond the normal signature/identity checks - that is the default behavior of “a replay is a new delivery.”

See Identity, retries, and replay for the full identity model this relies on.

Every published CloudEvent carries three origin extension attributes, named exactly as shown (structured representation; the equivalent ce- prefixed headers exist in binary mode):

  • barakahoriginkind - one of user, mcp, system, or device.
  • barakahoriginconnection - an opaque string identifying the writing connection. It is stable for events written by the same connection and is not a human-readable identifier.
  • barakahcorrelationid - optional. Present when the writer (or the MCP tool call that triggered a server-side write) supplied a caller correlation_id.

A consumer that reacts to a webhook by writing back into Barakah through MCP is the classic loop hazard: it receives an event, calls an MCP write tool, which publishes a new event, which the same subscription may deliver right back to it. The fix has two parts:

  1. Send correlation_id on every MCP write your automation makes in response to an event, and remember the IDs you minted.
  2. Drop any incoming event whose barakahoriginconnection equals your own connection ref, or whose barakahcorrelationid is one you minted yourself. Everything else should be processed normally.

A minimal, dependency-free implementation of that rule, shouldProcess, is published as loop-safety.mjs - import it the same way as the verifier:

import { shouldProcess } from './loop-safety.mjs';
if (!shouldProcess(event, { ownConnectionRef, mintedCorrelationIds })) {
return; // this is an echo of our own write; ignore it
}

device-origin events are not server-verified presence. Events like notification.action_completed and proximity.arrived/proximity.left carry barakahoriginkind: "device" because they originate from a device-reported observation (a notification action, a geofence trigger), never from a server-side confirmation of where a device physically is. Treat them as what a device claimed happened, not as ground truth, and apply the same loop-safety rule to them as to any other origin kind - device is orthogonal to whether an event is a self-authored echo.