Skip to main content

Webhook receiver helpers

Marea signs every webhook with HMAC-SHA256 over "<unix-ts>.<rawBody>" using the endpoint’s raw signing secret (the 64-char hex string returned at create / rotate time). The signature is in the X-Marea-Signature request header:
X-Marea-Signature: t=1714867200,v1=abc123def456... (64-char hex)
Reject any webhook where |now - t| > 300 (5-minute replay window).

Node.js / TypeScript

import * as crypto from 'crypto';

const REPLAY_WINDOW_SECONDS = 300;

/**
 * Verify a Marea 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 secret     The endpoint's `signingSecret` (64-char hex, returned at create/rotate)
 */
export function verifyMareaWebhook(
  rawBody: string | Buffer,
  sigHeader: string | undefined,
  secret: string,
): boolean {
  if (!sigHeader) return false;
  if (!/^[0-9a-f]{64}$/i.test(secret)) 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;
  }

  const bodyString = Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody;
  const signingKey = Buffer.from(secret, 'hex');
  const expected = crypto
    .createHmac('sha256', signingKey)
    .update(`${ts}.${bodyString}`)
    .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-webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body.toString('utf8');

    // Look up the secret for the endpoint that produced this delivery.
    // If you only have one endpoint, a single env var is enough.
    const endpointId = req.headers['x-marea-endpoint-id'] as string | undefined;
    const secret = lookupSecretForEndpoint(endpointId)
      ?? process.env.MAREA_WEBHOOK_SECRET!;

    const ok = verifyMareaWebhook(
      rawBody,
      req.headers['x-marea-signature'] as string | undefined,
      secret,
    );
    if (!ok) return res.status(401).end();

    const envelope = JSON.parse(rawBody);
    // envelope.type === 'user.verified' | 'user.cancelled' | 'order.created' | ...
    // envelope.eventId is a UUID v4 — use it for idempotency
    handleEventAsync(envelope).catch(console.error);
    res.status(200).end();
  },
);

Python

import hmac
import hashlib
import re
import time
from typing import Optional, Union

REPLAY_WINDOW_SECONDS = 300
_HEX64 = re.compile(r'^[0-9a-fA-F]{64}$')


def verify_marea_webhook(
    raw_body: Union[bytes, str],
    signature_header: Optional[str],
    secret: str,
) -> bool:
    """Verify a Marea webhook signature. Returns True iff valid.

    secret: the endpoint's `signingSecret` (64-char hex), returned at
    create/rotate time.
    """
    if not signature_header or not _HEX64.match(secret):
        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 or not re.match(r'^[0-9a-f]+$', sig):
        return False

    if abs(int(time.time()) - ts) > REPLAY_WINDOW_SECONDS:
        return False

    body_bytes = raw_body if isinstance(raw_body, bytes) else raw_body.encode('utf-8')
    signing_key = bytes.fromhex(secret)
    signed_string = f"{ts}.".encode('utf-8') + body_bytes
    expected = hmac.new(signing_key, signed_string, hashlib.sha256).hexdigest()

    return hmac.compare_digest(expected, sig)

Multiple endpoints, one receiver

If you serve multiple endpoints from one HTTP server, dispatch on the X-Marea-Endpoint-Id header and look up the corresponding secret:
const SECRETS = {
  mk_we_a1b2c3d4e5f60789: process.env.MAREA_PROD_SECRET,
  mk_we_1a2b3c4d5e6f0789: process.env.MAREA_STAGING_SECRET,
};

function lookupSecretForEndpoint(endpointId) {
  return SECRETS[endpointId];
}

Source disambiguation (developer vs merchant)

The X-Marea-Source header tells you which kind of activity produced the delivery:
X-Marea-SourceMeaning
developerA user.* lifecycle event on an account your developer key bootstrapped
merchantAn order.* event from a storefront owned by an account your developer key bootstrapped
Either way, the signature is computed with the same algorithm using the endpoint’s own signingSecret. You do not need to maintain a different verifier per source.

Idempotency

Every envelope includes an eventId (UUID v4) as a stable idempotency key across retries:
async function handleEventAsync(envelope) {
  const seen = await redis.set(
    `marea:${envelope.eventId}`,
    '1',
    'EX',
    86400,
    'NX',
  ); // 24h TTL, only-if-not-exists
  if (!seen) return; // already processed

  await yourBusinessLogic(envelope);
}
Marea retries failed deliveries up to 3 times (0s / +30s / +5min). The retry payload uses the same eventId.

Detecting in-flight secret rotation

Every delivery includes X-Marea-Signing-Version (an integer that bumps each time you rotate the endpoint’s secret). If you receive a delivery with a version number you’ve never seen, assume rotation happened — pull the new secret from your secret manager (or the dashboard reveal modal if you triggered the rotate yourself) before retrying signature verification.