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.
You must verify the x-api-signature on every webhook request before
trusting or acting on its payload. Skipping verification lets an attacker send
forged requests directly to your webhook URL — for example fabricating a
success payment status for an order that was never paid. Never treat an
unverified webhook body as authoritative.
IP allowlisting
As an additional layer of defense on top of signature verification, restrict inbound traffic on your webhook endpoint to Morph Payments' egress IP addresses:
| Egress IP |
|---|
13.114.0.94 |
54.199.78.128 |
Configure your firewall, load balancer, or ingress rules to only accept webhook requests from these IPs. This reduces the attack surface even if signature verification is misconfigured, but it does not replace it — always verify the signature as well.
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.