> ## 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.

# Webhook receiver helpers

> Verify Marea webhook signatures (all event types). HMAC-SHA256 with the endpoint's raw 32-byte signing secret; 5-min replay window.

# 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

```typescript theme={null}
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

```python theme={null}
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:

```javascript theme={null}
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-Source` | Meaning                                                                                  |
| ---------------- | ---------------------------------------------------------------------------------------- |
| `developer`      | A `user.*` lifecycle event on an account your developer key bootstrapped                 |
| `merchant`       | An `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:

```javascript theme={null}
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.
