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.

Page-webhook receiver helpers

Page webhooks (order.created, order.status_updated, order.paid) are signed with HMAC-SHA256 using the raw 32-byte hex secret revealed when you first saved the webhook.
Differs from Agent webhooks: Agent webhooks (user.verified, user.cancelled) derive the signing key via HKDF-SHA256 from the developer-key hash. Page webhooks skip the HKDF step — your secret IS the HMAC key. Use the X-Marea-Source: merchant header to disambiguate if your endpoint receives both surfaces.

Node.js / TypeScript

import * as crypto from 'crypto';

const REPLAY_WINDOW_SECONDS = 300;

interface VerifyResult {
  valid: boolean;
  reason?: 'no_header' | 'malformed_header' | 'replay_window' | 'signature_mismatch';
}

export function verifyMareaPageWebhook(
  rawBody: string,            // EXACT bytes from req.body — do NOT JSON.parse and re-stringify
  signatureHeader: string | undefined,
  signingSecretHex: string,   // The raw 32-byte hex secret Marea revealed on save
): VerifyResult {
  if (!signatureHeader) {
    return { valid: false, reason: 'no_header' };
  }

  // Parse "t=<unix-ts>,v1=<hex-hmac>" (Stripe-style)
  const parts = Object.fromEntries(
    signatureHeader.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 { valid: false, reason: 'malformed_header' };
  }

  // 5-minute replay window
  const nowSeconds = Math.floor(Date.now() / 1000);
  if (Math.abs(nowSeconds - ts) > REPLAY_WINDOW_SECONDS) {
    return { valid: false, reason: 'replay_window' };
  }

  // Page webhooks: secret is the RAW hex (no HKDF derivation)
  const secret = Buffer.from(signingSecretHex, 'hex');
  const signedString = `${ts}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedString)
    .digest('hex');

  // Constant-time compare
  const expectedBuf = Buffer.from(expected, 'hex');
  const actualBuf = Buffer.from(sig, 'hex');
  if (expectedBuf.length !== actualBuf.length) {
    return { valid: false, reason: 'signature_mismatch' };
  }
  return crypto.timingSafeEqual(expectedBuf, actualBuf)
    ? { valid: true }
    : { valid: false, reason: 'signature_mismatch' };
}

// Express handler example — note we read req.body as Buffer/string, NOT a parsed object
import express from 'express';
const app = express();

app.post(
  '/marea-webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body.toString('utf8');
    const result = verifyMareaPageWebhook(
      rawBody,
      req.headers['x-marea-signature'] as string | undefined,
      process.env.MAREA_WEBHOOK_SECRET!,
    );
    if (!result.valid) {
      console.warn('Marea webhook rejected:', result.reason);
      return res.status(401).end();
    }

    const event = JSON.parse(rawBody);
    // event.type === 'order.created' | 'order.status_updated' | 'order.paid'
    // event.data has the typed payload
    // event.eventId — use as idempotency key
    handleEventAsync(event).catch(console.error);
    res.status(200).end();
  },
);

Python

import hmac
import hashlib
import time
from typing import Optional

REPLAY_WINDOW_SECONDS = 300

def verify_marea_page_webhook(
    raw_body: bytes,                # EXACT bytes from request body
    signature_header: Optional[str],
    signing_secret_hex: str,        # The raw 32-byte hex secret
) -> bool:
    """Verify a Page-webhook signature. Returns True iff valid."""
    if not signature_header:
        return False

    # Parse "t=<unix-ts>,v1=<hex-hmac>"
    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

    # 5-minute replay window
    if abs(int(time.time()) - ts) > REPLAY_WINDOW_SECONDS:
        return False

    # Page webhooks: secret is the RAW hex (no HKDF derivation)
    try:
        secret = bytes.fromhex(signing_secret_hex)
    except ValueError:
        return False

    signed_string = f"{ts}.".encode('utf-8') + raw_body
    expected = hmac.new(secret, signed_string, hashlib.sha256).hexdigest()

    return hmac.compare_digest(expected, sig)


# Flask handler example
from flask import Flask, request, abort
import os, json

app = Flask(__name__)

@app.route('/marea-webhook', methods=['POST'])
def handle_marea_webhook():
    raw_body = request.get_data()  # bytes; do NOT use request.json
    if not verify_marea_page_webhook(
        raw_body,
        request.headers.get('X-Marea-Signature'),
        os.environ['MAREA_WEBHOOK_SECRET'],
    ):
        abort(401)

    event = json.loads(raw_body)
    # event['type'] in ('order.created', 'order.status_updated', 'order.paid')
    # event['data'] has the typed payload
    # event['eventId'] — use as idempotency key
    handle_event_async(event)
    return '', 200

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'];
  let result;
  if (source === 'merchant') {
    // Page webhook — raw secret (no HKDF)
    result = verifyMareaPageWebhook(
      req.body.toString('utf8'),
      req.headers['x-marea-signature'],
      process.env.MAREA_PAGE_WEBHOOK_SECRET,
    );
  } else {
    // Agent webhook — HKDF-derived from developer key hash
    result = verifyMareaAgentWebhook(
      req.body.toString('utf8'),
      req.headers['x-marea-signature'],
      process.env.MAREA_AGENT_WEBHOOK_KEY_HASH,
    );
  }
  if (!result.valid) return res.status(401).end();
  // ...
}
See Agent-webhook receiver helpers for the HKDF-derived verification path.

Idempotency on eventId

Marea retries failed deliveries up to 3 times. 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) {
    console.log('Duplicate Marea event, skipping:', event.eventId);
    return;
  }
  // First time seeing this event — process it
  await yourBusinessLogic(event);
}
The eventId (UUID v4) is generated at dispatch time and is the same across all retry attempts of the same logical event.