Skip to content

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.

Every signed request carries three headers:

HeaderMeaning
webhook-idThe message id — stable across retries; your idempotency key.
webhook-timestampUnix seconds when the message was signed. Used to reject replays.
webhook-signatureOne or more space-separated signatures, each v1,<base64>.

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}`

When 2mee calls your endpoint, verify before trusting the body:

  1. Reject if webhook-timestamp is more than 5 minutes from now (allow small clock skew).
  2. Recompute the signature with your endpoint’s secret and compare with a constant-time comparison.
  3. Accept if any signature in the header matches (there can be two during secret rotation).
  4. Dedupe on webhook-id so 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
<?php
function 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)
end
end

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.

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.

Was this page helpful? Tell us / report an issue ↗