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.