Skip to main content

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.

Signature verification is mandatory

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:

HeaderDescription
x-api-keyYour API key, identifying which credential signed the request.
x-api-timestampSigning time as a Unix millisecond timestamp (decimal string).
x-api-signatureBase64-encoded HMAC-SHA256 signature to verify.
x-api-event-idThe 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:

KeyValue
x-api-keyThe x-api-key header value.
x-api-timestampThe x-api-timestamp header value.
pathThe path component of your webhook URL (for example /hook), excluding any query string.
bodyThe 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.

caution

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

  1. Read the raw request body (before JSON parsing).
  2. Read the x-api-key, x-api-timestamp, and x-api-signature headers.
  3. Take the path from the webhook URL you registered.
  4. Build the canonical JSON string with keys in alphabetical order: body, path, x-api-key, x-api-timestamp.
  5. Compute the HMAC-SHA256 of that string using your apiSecret as the key, then Base64-encode it.
  6. Compare the result to x-api-signature using 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),
);
}
note

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"])
caution

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.