Skip to main content

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.