Documentation Index
Fetch the complete documentation index at: https://docs.mareaalcalina.com/llms.txt
Use this file to discover all available pages before exploring further.
Agent-webhook receiver helpers
Agent webhooks (user.verified, user.cancelled) are signed with HMAC-SHA256. The signature header looks like:
X-Marea-Signature: t=1714867200,v1=abc123def456...
Reject any webhook where the timestamp is older than 5 minutes (replay-protection window).
Differs from Page webhooks. Agent-webhook signing keys are HKDF-SHA256-derived from your developer key’s stored hash (apiKeys/{keyId}.keyHash). Page webhooks (order.*) use a raw 32-byte hex secret with no HKDF step. If your single endpoint receives both surfaces, branch on X-Marea-Source (developer vs merchant) to pick the right verifier — see Page-webhook receiver helpers.
Node.js / TypeScript
import * as crypto from 'crypto';
const REPLAY_WINDOW_SECONDS = 300;
const HKDF_INFO = 'marea-webhook-v1';
/**
* Verify a Marea Agent-webhook signature.
*
* @param rawBody Raw bytes from req.body — do NOT JSON.parse and re-stringify
* @param sigHeader Value of the X-Marea-Signature header ("t=...,v1=...")
* @param keyHashHex Your developer-key hash (hex). Marea stores it server-side
* under apiKeys/{keyId}.keyHash; you store the same value
* when you first issue the key.
*/
export function verifyMareaAgentWebhook(
rawBody: string,
sigHeader: string | undefined,
keyHashHex: string,
): boolean {
if (!sigHeader) return false;
const parts = Object.fromEntries(
sigHeader.split(',').map((p) => {
const [k, v] = p.split('=');
return [k.trim(), v?.trim()];
}),
);
const ts = Number(parts.t);
const sig = parts.v1;
if (!Number.isInteger(ts) || ts <= 0 || !sig || !/^[0-9a-f]+$/.test(sig)) return false;
// 5-minute replay window
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > REPLAY_WINDOW_SECONDS) return false;
// HKDF-SHA256 derive signing key from your developer-key hash
const ikm = Buffer.from(keyHashHex, 'hex');
const signingKey = crypto.hkdfSync('sha256', ikm, Buffer.alloc(0), Buffer.from(HKDF_INFO, 'utf8'), 32);
const expected = crypto
.createHmac('sha256', Buffer.from(signingKey))
.update(`${ts}.${rawBody}`)
.digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const actualBuf = Buffer.from(sig, 'hex');
if (expectedBuf.length !== actualBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, actualBuf);
}
// Express handler — read req.body as Buffer/string, NOT a parsed object
import express from 'express';
const app = express();
app.post(
'/marea-agent-webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8');
const ok = verifyMareaAgentWebhook(
rawBody,
req.headers['x-marea-signature'] as string | undefined,
process.env.MAREA_DEV_KEY_HASH!,
);
if (!ok) return res.status(401).end();
const event = JSON.parse(rawBody);
// event.type === 'user.verified' | 'user.cancelled'
handleEventAsync(event).catch(console.error);
res.status(200).end();
},
);
Python
import hmac
import hashlib
import time
from typing import Optional
REPLAY_WINDOW_SECONDS = 300
HKDF_INFO = b'marea-webhook-v1'
def _hkdf_sha256(ikm: bytes, info: bytes, length: int = 32) -> bytes:
"""HKDF-SHA256 with empty salt (matches the cloud-functions canonical impl)."""
prk = hmac.new(b'\x00' * hashlib.sha256().digest_size, ikm, hashlib.sha256).digest()
okm = b''
t = b''
counter = 1
while len(okm) < length:
t = hmac.new(prk, t + info + bytes([counter]), hashlib.sha256).digest()
okm += t
counter += 1
return okm[:length]
def verify_marea_agent_webhook(
raw_body: bytes,
signature_header: Optional[str],
key_hash_hex: str,
) -> bool:
"""Verify a Marea Agent-webhook signature. Returns True iff valid."""
if not signature_header:
return False
parts = {}
for kv in signature_header.split(','):
if '=' not in kv:
continue
k, v = kv.split('=', 1)
parts[k.strip()] = v.strip()
try:
ts = int(parts.get('t', '0'))
except ValueError:
return False
sig = parts.get('v1', '')
if ts <= 0 or not sig:
return False
if abs(int(time.time()) - ts) > REPLAY_WINDOW_SECONDS:
return False
try:
ikm = bytes.fromhex(key_hash_hex)
except ValueError:
return False
signing_key = _hkdf_sha256(ikm, HKDF_INFO, 32)
signed_string = f"{ts}.".encode('utf-8') + raw_body
expected = hmac.new(signing_key, signed_string, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
There’s also a runnable port at helpers/python/verify_webhook.py that takes the already-derived signing key directly — useful if you cache the HKDF result.
Distinguishing Page vs Agent webhooks
If your single endpoint serves both surfaces, branch on X-Marea-Source:
function handleMareaWebhook(req, res) {
const source = req.headers['x-marea-source'];
const ok = source === 'merchant'
? verifyMareaPageWebhook(req.body.toString('utf8'), req.headers['x-marea-signature'], process.env.MAREA_PAGE_WEBHOOK_SECRET)
: verifyMareaAgentWebhook(req.body.toString('utf8'), req.headers['x-marea-signature'], process.env.MAREA_DEV_KEY_HASH);
if (!ok) return res.status(401).end();
// ...
}
See Page-webhook receiver helpers for the raw-secret variant.
Idempotency on eventId
Marea retries failed deliveries up to 3 times (0s / +30s / +5min). To guard against duplicates:
async function handleEventAsync(event) {
const seen = await redis.set(
`marea:event:${event.eventId}`,
'1',
'EX', 86400, 'NX', // 24h TTL, only-if-not-exists
);
if (!seen) return;
await yourBusinessLogic(event);
}
The eventId (UUID v4) is generated at dispatch time and is identical across all retry attempts of the same logical event.