Signature Verification
Signature Verification
Every webhook request sent by Morph Payments is signed with an HMAC-SHA256
signature derived from your apiSecret. Verifying the signature lets you confirm
that a request genuinely came from Morph Payments and that its body was not
altered in transit.
Always verify the signature before acting on a webhook payload.
Request headers
Each webhook request includes the following headers:
| Header | Description |
|---|---|
x-api-key | Your API key, identifying which credential signed the request. |
x-api-timestamp | Signing time as a Unix millisecond timestamp (decimal string). |
x-api-signature | Base64-encoded HMAC-SHA256 signature to verify. |
x-api-event-id | The event_id of the delivered event. |
How the signature is computed
The signature is an HMAC-SHA256, keyed with your apiSecret, computed over a
canonical JSON string built from four values:
| Key | Value |
|---|---|
x-api-key | The x-api-key header value. |
x-api-timestamp | The x-api-timestamp header value. |
path | The path component of your webhook URL (for example /hook), excluding any query string. |
body | The raw request body, exactly as received. |
These are serialized into a JSON object with keys in alphabetical order, so the signed string is always:
{"body":<body>,"path":<path>,"x-api-key":<key>,"x-api-timestamp":<timestamp>}
The HMAC-SHA256 of that string (keyed with apiSecret) is then Base64-encoded to
produce the x-api-signature value.
Verify against the raw request body bytes. Do not re-serialize the parsed JSON before verifying — reordering keys or changing whitespace will change the body and cause the signature to mismatch.
Verifying a request
- Read the raw request body (before JSON parsing).
- Read the
x-api-key,x-api-timestamp, andx-api-signatureheaders. - Take the
pathfrom the webhook URL you registered. - Build the canonical JSON string with keys in alphabetical order:
body,path,x-api-key,x-api-timestamp. - Compute the HMAC-SHA256 of that string using your
apiSecretas the key, then Base64-encode it. - Compare the result to
x-api-signatureusing a constant-time comparison. Reject the request if they do not match.
Node.js
const crypto = require('crypto');
function verify(req, apiSecret, webhookPath) {
const apiKey = req.headers['x-api-key'];
const timestamp = req.headers['x-api-timestamp'];
const received = req.headers['x-api-signature'];
const rawBody = req.rawBody; // the exact bytes received
const content = JSON.stringify({
body: rawBody.toString(),
path: webhookPath,
'x-api-key': apiKey,
'x-api-timestamp': timestamp,
});
const expected = crypto
.createHmac('sha256', apiSecret)
.update(content)
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(received),
);
}
JSON.stringify above produces keys in the order they are written. Keep the
order body, path, x-api-key, x-api-timestamp so the string matches the
canonical form.
Python
import hmac
import hashlib
import base64
import json
def verify(headers, raw_body: bytes, api_secret: str, webhook_path: str) -> bool:
content = json.dumps({
"body": raw_body.decode(),
"path": webhook_path,
"x-api-key": headers["x-api-key"],
"x-api-timestamp": headers["x-api-timestamp"],
}, separators=(",", ":"), sort_keys=True)
expected = base64.b64encode(
hmac.new(api_secret.encode(), content.encode(), hashlib.sha256).digest()
).decode()
return hmac.compare_digest(expected, headers["x-api-signature"])
Use compact JSON separators (no spaces) so the serialized string matches the signed content byte-for-byte.
Rejecting invalid requests
If verification fails, respond with a 4xx status and do not process the payload.
You may also reject requests whose x-api-timestamp is far from your current
server time to limit replay of old requests.