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.

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.