Inbound signing & verification
import { Tabs, TabItem } from “@astrojs/starlight/components”;
2mee’s generic webhook scheme signs and verifies requests with HMAC-SHA256 over the raw request body. It’s the same scheme in both directions: you use it to sign requests to 2mee, and to verify requests 2mee sends to you. It follows the Standard Webhooks conventions.
Headers
Section titled “Headers”Every signed request carries three headers:
| Header | Meaning |
|---|---|
webhook-id | The message id — stable across retries; your idempotency key. |
webhook-timestamp | Unix seconds when the message was signed. Used to reject replays. |
webhook-signature | One or more space-separated signatures, each v1,<base64>. |
What is signed
Section titled “What is signed”The signed content is the three parts joined with a dot — id, timestamp, then the raw body bytes exactly as sent:
signedContent = `${webhookId}.${webhookTimestamp}.${rawBody}`signature = base64( HMAC_SHA256(secret, signedContent) )header = `v1,${signature}`Verifying a request from 2mee
Section titled “Verifying a request from 2mee”When 2mee calls your endpoint, verify before trusting the body:
- Reject if
webhook-timestampis more than 5 minutes from now (allow small clock skew). - Recompute the signature with your endpoint’s secret and compare with a constant-time comparison.
- Accept if any signature in the header matches (there can be two during secret rotation).
- Dedupe on
webhook-idso a retry can’t double-process.
import crypto from "node:crypto";
// rawBody: the exact request body bytes (Buffer or string), before JSON.parse.export function verify(rawBody, headers, secret) { const id = headers["webhook-id"]; const ts = headers["webhook-timestamp"]; const sigHeader = headers["webhook-signature"] ?? "";
// 1. Reject stale timestamps (5-minute window). const ageSec = Math.abs(Date.now() / 1000 - Number(ts)); if (!ts || ageSec > 300) return false;
// 2. Recompute the expected signature. const signedContent = `${id}.${ts}.${rawBody}`; const expected = crypto .createHmac("sha256", secret) .update(signedContent) .digest("base64");
// 3. Constant-time compare against any provided signature. const expectedBuf = Buffer.from(expected); return sigHeader.split(" ").some((part) => { const sig = part.startsWith("v1,") ? part.slice(3) : part; const sigBuf = Buffer.from(sig); return ( sigBuf.length === expectedBuf.length && crypto.timingSafeEqual(sigBuf, expectedBuf) ); });}import hashlib, hmac, time, base64
def verify(raw_body: bytes, headers: dict, secret: str) -> bool: wid = headers.get("webhook-id", "") ts = headers.get("webhook-timestamp", "") sig_header = headers.get("webhook-signature", "")
# 1. Reject stale timestamps (5-minute window). if not ts or abs(time.time() - int(ts)) > 300: return False
# 2. Recompute the expected signature. signed = f"{wid}.{ts}.".encode() + raw_body expected = base64.b64encode( hmac.new(secret.encode(), signed, hashlib.sha256).digest() ).decode()
# 3. Constant-time compare against any provided signature. for part in sig_header.split(" "): sig = part[3:] if part.startswith("v1,") else part if hmac.compare_digest(sig, expected): return True return False<?phpfunction verify(string $rawBody, array $headers, string $secret): bool { $id = $headers['webhook-id'] ?? ''; $ts = $headers['webhook-timestamp'] ?? ''; $sigHeader = $headers['webhook-signature'] ?? '';
// 1. Reject stale timestamps (5-minute window). if ($ts === '' || abs(time() - (int)$ts) > 300) return false;
// 2. Recompute the expected signature. $signed = $id . '.' . $ts . '.' . $rawBody; $expected = base64_encode(hash_hmac('sha256', $signed, $secret, true));
// 3. Constant-time compare against any provided signature. foreach (explode(' ', $sigHeader) as $part) { $sig = str_starts_with($part, 'v1,') ? substr($part, 3) : $part; if (hash_equals($expected, $sig)) return true; } return false;}require "openssl"require "base64"
def verify(raw_body, headers, secret) id = headers["webhook-id"].to_s ts = headers["webhook-timestamp"].to_s sig_header = headers["webhook-signature"].to_s
# 1. Reject stale timestamps (5-minute window). return false if ts.empty? || (Time.now.to_i - ts.to_i).abs > 300
# 2. Recompute the expected signature. signed = "#{id}.#{ts}.#{raw_body}" expected = Base64.strict_encode64( OpenSSL::HMAC.digest("SHA256", secret, signed) )
# 3. Constant-time compare against any provided signature. sig_header.split(" ").any? do |part| sig = part.start_with?("v1,") ? part[3..] : part OpenSSL.secure_compare(expected, sig) endendSigning a request to 2mee
Section titled “Signing a request to 2mee”To send to the ingest API yourself, build the same headers with your
connection’s secret and POST the raw body to https://hooks.2mee.com/v1/in/{inboundKey}.
Reuse the formula above to produce webhook-signature.
Provider schemes are handled for you
Section titled “Provider schemes are handled for you”If you connect a named provider, you don’t implement signing at all. For example, Segment
signs with its own scheme (HMAC-SHA1 in an x-signature header); 2mee verifies that against
your connection’s secret automatically. See Connect Segment.
The HMAC-SHA256 scheme on this page is the generic webhook scheme for systems without a
built-in connector.
Related
Section titled “Related”- The payload you sign: the ingest API.
- The event you verify: outbound envelope.
- Rotation & dedupe: retries & idempotency.