Webhook Security

Ensure that the webhook notifications you receive are genuinely from Maya

Whitelist Maya IP address

To boost security, apply a whitelisting mechanism and whitelist Maya IP addresses in your network. Webhook notifications should come from the IPs listed below:

EnvironmentIP Addresses
Sandbox13.229.160.234 3.1.199.75
Production18.138.50.235 3.1.207.200
  • Ensure to whitelist the correct IPs based on your current environment. Production environments should only whitelist Maya’s Production webhook IP addresses.
  • It is strongly recommended to configure your network to accept webhook traffic only from the IPs listed above for the corresponding environment.

Verify Maya's signature

The Webhook Signature Version 1 is designed to use RSA-SHA256, where public keys are provided to the webhook clients or receivers.

📘

Note

Maya highly recommends to validate the Webhook Signature and Timestamp, in addition to IP whitelisting, to ensure the authenticity and integrity of the webhook payload.

This implementation is a non-breaking change to existing webhook subscriptions.

Webhook Headers

There are two new headers introduced to bolster the security of webhooks:

X-Maya-Webhook-Signature

The X-Maya-Webhook-Signature header, a comma-separated dictionary, which contains webhook signature and nonce, which is used to verify the integrity of both the webhook and its payload

"X-Maya-Webhook-Signature": "nonce=ebda1e3fb8813e9c491775f0d25365bc,v1=0a6e4c98ebd146f12dc0c1afbf52823a78d647f1f5add2de092ecf1ef2b1cd7d92babed9c54fb1aab510132c3accc1973657519f5cff14172d040851a1743b9c04728dc9ed08d275bd30db3ae04c8c6f49240d04ed51e336a7df790e1099888640afc721d5522d94a83069a1384a08be95c2f0f52eebfd9886cb04d58c60fcd393a0caf2c30e690f4528c1fca13a462f51e8d5e06dc4f7454608ddc01b7816f3b40dc7f2eacf9a6710d6c1cf98ce23af980ddcb69f84d276f1e096d1fff567d62ed367e8973422767116bafbee28b477a6e76c03395f8ea629b4103630016b86ae699993b268de91b56c1f28fee0fede97e087bc7c140b568371f4aff8758ec6"

Parsing X-Maya-Webhook-Signature

The header value is a comma-separated list of key=value pairs. Split on ,, then split each segment on the first =.

  • nonce — random value used when building the signed string (see Webhook Signature Verification).
  • v1 — signature for Webhook Signature Version 1, encoded as a hexadecimal string.

Example:

nonce=4f6e517387d0a3fcb36ec0a15b04c654,v1=735191a366cba788...

Use the v1 value with RSA-SHA256 verification (hex). If a future version adds v2, follow the specification for that version.

X-Maya-Webhook-Timestamp

The X-Maya-Webhook-Timestamp header is the time Maya sent the webhook, as Unix time in milliseconds (not seconds). The value may change when Maya retries the webhook.

"X-Maya-Webhook-Timestamp": 1747707392104

Replay Attacks Prevention

The X-Maya-Webhook-Timestamp indicates when Maya sent the webhook. Because Maya may retry delivery, do not treat the timestamp alone as a unique event ID—the same payment may arrive more than once with an updated timestamp.

To reduce replay risk:

  1. Validate the timestamp before you process the payload. Reject the webhook if the timestamp is outside an acceptable window (for example, 5 minutes—see Webhook Timestamp Validation under Sample Code).
  2. Verify the signature using the steps in Webhook Signature Verification. The nonce in the signature header is part of the signed string; it binds the signature to that delivery attempt.
  3. Make your handler idempotent (safe to run twice for the same payment), using fields such as payment id and status, not only the webhook timestamp.

Recommended order when a webhook arrives

  1. (Optional) Confirm the request source IP is in the allowlist for your environment.
  2. Read X-Maya-Webhook-Timestamp and X-Maya-Webhook-Signature.
  3. Parse the JSON request body into an object (use that object for flattening and verification—do not reformat the raw body string unless your stack requires it and you have confirmed it matches Maya’s signing input).
  4. Validate the timestamp.
  5. Parse the signature header, then verify the signature with Maya’s public keys (KEY1, then KEY2).
  6. If all checks pass, process the notification; otherwise, reject the request and do not update order or payment state.

Zero-Downtime Key Rotation

Maya provides 2 public keys. Webhook clients must install and use both 2 public keys when verifying webhook signature.

In the future, the primary key will be due for rotation, Maya will use the other key for succeeding signature generation.

🚧

Warning

Maya will advise webhook clients to rotate key on regular basis. Webhook clients must update the list.

Webhook Signature Public Key

Get Maya’s 2 public keys per environment.

Sandbox

// sandbox-pk-1

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjNkSX6p+goDPaPAYuTzT
zKTCBeLhh8FkPMbZxDKTUxF93dOwiC7jsdx7KyopupeLosiVlbs+gpAJ7XBQP/Ex
giyzXC9TljpyvkUQfyRPMAMKq+BzxdUliTl6hgrLBsH28CP5FuPHCsfxDXe7mDtv
9H4mP3SKO0HfkZ45tudxD9CWbwWKF0lU9LRbLlJ0y7KEaK7Rv9fI1Dp/KPT+9pls
tU+CPNKaxJjGRKGuxW2AOCabSD0cTZNXki+K51mNoma7Mj1HMhnsR68FGJvCqk1q
Wsr3q8+EUMVPBMX+5nKATfZYGvxg4ytzT8pnEVeWl6phYKviB9aVVwurh1gDJB4r
lQIDAQAB
-----END PUBLIC KEY-----
// sandbox-pk-2
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp14gezqq4dGWu7EZ7BHx
8wD3y1hqxwQR7UYPXtXJP+WngN4wqwatjsnQaRGnmdPRG8VEzUzw9PlR7t7P24uW
+J08xBrtTVouD2MKglcIcy13rt1XL79zr/LIAFMFI6f4O8/OQi1xsGsZ6xarD+wl
OQKG4W66I3yp2jNAbge25eSPuo0BNqPWvebMcIYJu4f3Fxu1eDgeM6zCEqLc6+jX
cNTP/zFHCvQaiIlLOqfgXDRPBcHPPZ2qcB99UVPAHXBKsKdtBB2w2qT2l99MlTAB
iRy+IKtVQcQyRP7T8blegO25x35G2CZ3VCKPkmUen3eXQ4+r5fVlzEIBSfNvBwT9
jQIDAQAB
-----END PUBLIC KEY-----

Production

// production-pk-1

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjCGhkjg1PQe0WVHCYdTT
2luqzXhKfeStALWlEcMpHqYusd6dAU4vZ9bGQns/OYe/H2cIxEPvRJnRcipMKvVZ
pzAFEKHQLiXdeuNcxkAaxEZEwMAmFdVGmNLZbpi579r2s6Q++zYy0OHb9awY/2z0
OYRwV5XN7SCrqIlf1tEHfxKV2cJDCFW030nnRMoWisQ9KXG3Ihvjj4tOQimPCtzp
SDtlf6QFmg/WZBIOEdLro9oROztK6PwrI/yG5ZFaUCQYfY8fw0y1/PI3heEf8z5k
xA466LdSqCeVdGwfjKy9ZHown8XiiPI82HnBrMP3UPX4efEfopbP4SpDFOEwRNA9
FQIDAQAB
-----END PUBLIC KEY-----
// production-pk-2


-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxZJxNmpNYjxFCBa2P6Ad
wzDDuDKOKAgiTBrQvJGuX/l2u32N4d4FYw99md16rf1iIcxD70/KG9nWrltrxbIs
bm9+bCHVLKMfdjaJQCBGXN/WW6W1XaGQQPft9UlmAwA/uMKTsN/2XqFjoKSJoe9e
Xz/p3pGn66oBTCwvzDqma46GxF92atiOt6CEcRl8P+dDKJlYY7fcxiuNMeDMOOla
KMxUz9nMgJ6uESK/kS8C8+hGuiCWgKeIRm/ONL5Gk/lypWzrphaKcWqpBGZxpNAL
AVmPY9ke4+RxyojkEre4d5sT2C21oAQVHyGewd0ttQ/bK59X17+yg5FOfRpI1BKj
7wIDAQAB
-----END PUBLIC KEY-----

Webhook Signature Verification

Step 1. Assume KEY1 and KEY2 are the public keys shared by Maya.

Step 2. Assume that the webhook headers are:

{
  "X-Maya-Webhook-Signature": "nonce=4f6e517387d0a3fcb36ec0a15b04c654,v1=735191a366cba78821520cc19439fc28846375c7ab1e0b2ee71dc6e2492880f7f28bd88f9ca3bc58d0cc46a7ab94596bfd8cfc3a488754654264e13db9c48a35fcded7c1b5238beddf4d1a587340e1f8771f73707c5fd406f9b50260e5054da8d9436e638ff8382191afb5415bb31bc0a73008870deba759d0698b80dca5ec4ca2fd1910e3df3c1c236de42c3fc09ccdcb466f579cc8459c65b54e7b04d1854a84d457aab4cc72200ea09ee5e39347cf16177a58990c73c55b40642970d51a7482a358a615b3b913267c2d5ee8037643f5d63dfd04615e6a5a21b12c12b0e1a9815f6dfdd86b660a795160da7694d2386c869698c81f2217ca56568688ddf7ce",
  "X-Maya-Webhook-Timestamp": 1747713653487
}

Step 3. Assume that the payload is:

{
  "id": "e732f996-cb87-4120-b712-166d8183c01d",
  "isPaid": true,
  "status": "PAYMENT_SUCCESS",
  "amount": 10,
  "currency": "PHP",
  "canVoid": true,
  "canRefund": true,
  "canCapture": false,
  "createdAt": "2021-07-01T08:26:13.000Z",
  "updatedAt": "2021-07-01T08:26:16.000Z",
  "fundSource": {
    "type": "paymaya",
    "description": "PayMaya Account",
    "details": {
      "scheme": "master-card",
      "last4": "4154",
      "first6": "545301",
      "masked": "545301******4154",
      "issuer": "Others"
    }
  }
}

Step 4. Extract the v1 signature from X-Maya-Webhook-Signature.

735191a366cba78821520cc19439fc28846375c7ab1e0b2ee71dc6e2492880f7f28bd88f9ca3bc58d0cc46a7ab94596bfd8cfc3a488754654264e13db9c48a35fcded7c1b5238beddf4d1a587340e1f8771f73707c5fd406f9b50260e5054da8d9436e638ff8382191afb5415bb31bc0a73008870deba759d0698b80dca5ec4ca2fd1910e3df3c1c236de42c3fc09ccdcb466f579cc8459c65b54e7b04d1854a84d457aab4cc72200ea09ee5e39347cf16177a58990c73c55b40642970d51a7482a358a615b3b913267c2d5ee8037643f5d63dfd04615e6a5a21b12c12b0e1a9815f6dfdd86b660a795160da7694d2386c869698c81f2217ca56568688ddf7ce

Step 5. Extract the nonce from X-Maya-Webhook-Signature.

4f6e517387d0a3fcb36ec0a15b04c654

Step 6. Flatten the webhook payload

id=e732f996-cb87-4120-b712-166d8183c01d
isPaid=true
status=PAYMENT_SUCCESS
amount=10
currency=PHP
canVoid=true
canRefund=true
canCapture=false
createdAt=2021-07-01T08:26:13.000Z
updatedAt=2021-07-01T08:26:16.000Z
fundSource.type=paymaya
fundSource.description=PayMaya Account
fundSource.details.scheme=master-card
fundSource.details.last4=4154
fundSource.details.first6=545301
fundSource.details.masked=545301******4154
fundSource.details.issuer=Others

Step 7. Sort lexicographically the flattened object:

amount=10
canCapture=false
canRefund=true
canVoid=true
createdAt=2021-07-01T08:26:13.000Z
currency=PHP
fundSource.description=PayMaya Account
fundSource.details.first6=545301
fundSource.details.issuer=Others
fundSource.details.last4=4154
fundSource.details.masked=545301******4154
fundSource.details.scheme=master-card
fundSource.type=paymaya
id=e732f996-cb87-4120-b712-166d8183c01d
isPaid=true
status=PAYMENT_SUCCESS
updatedAt=2021-07-01T08:26:16.000Z

Step 8. Concatenate the strings using the separator &.

amount=10&canCapture=false&canRefund=true&canVoid=true&createdAt=2021-07-01T08:26:13.000Z&currency=PHP&fundSource.description=PayMaya Account&fundSource.details.first6=545301&fundSource.details.issuer=Others&fundSource.details.last4=4154&fundSource.details.masked=545301******4154&fundSource.details.scheme=master-card&fundSource.type=paymaya&id=e732f996-cb87-4120-b712-166d8183c01d&isPaid=true&status=PAYMENT_SUCCESS&updatedAt=2021-07-01T08:26:16.000Z

Step 9. Append the nonce from step #5.

amount=10&canCapture=false&canRefund=true&canVoid=true&createdAt=2021-07-01T08:26:13.000Z&currency=PHP&fundSource.description=PayMaya Account&fundSource.details.first6=545301&fundSource.details.issuer=Others&fundSource.details.last4=4154&fundSource.details.masked=545301******4154&fundSource.details.scheme=master-card&fundSource.type=paymaya&id=e732f996-cb87-4120-b712-166d8183c01d&isPaid=true&status=PAYMENT_SUCCESS&updatedAt=2021-07-01T08:26:16.000Z&nonce=4f6e517387d0a3fcb36ec0a15b04c654

Step 10. Verify the signature using KEY1.

Step 11. If signature verification fails on KEY1, proceed with KEY2.

Step 12. If signature verification succeeds using KEY1 or KEY2, proceed with processing of the webhook notification.

Step 13. Otherwise, if signature verification fails, reject the webhook notification.

Sample Code

Flattening the Payload

When flattening, exclude the entries of keys with null, undefined, empty string, empty array and empty object.

/**
 * Flatten an object into a string of key-value pairs
 * @param obj - The object to flatten
 * @param prefix - Prefix for nested keys
 * @param data - Array to store key-value pairs
 * @returns Array of key-value pairs in format "key1=value1"
 */
export function flattenObjectToString(
  obj: Record<string, any>,
  prefix: string = '',
  data: string[] = []
): string[] {
  if (typeof obj !== 'object' || obj === null) {
    throw new Error('Input must be an object');
  }
  Object.entries(obj).forEach(([key, value]) => {
    const fullKey = prefix ? `${prefix}.${key}` : key;

    if (
      value === null ||
      value === '' ||
      typeof value === 'undefined' ||
      (Array.isArray(value) && !value.length) ||
      (typeof value === 'object' && !Object.keys(value).length)
    ) {
      return;
    }

    // flatten object and array values
    if (typeof value === 'object') {
      return flattenObjectToString(value, fullKey, data);
    }

    data.push(`${fullKey}=${String(value)}`);
  });
  return data;
}

Signature Verification

// javascript
const mayaWebhookPublicKeys = [
  'KEY1',
  'KEY2'
];

function verifySignatureV1(payload, signature, nonce) {
  const flatString = flattenObjectToString(payload).sort().join('&');
  const verifyString = `${flatString}&nonce=${nonce}`;
  
  return mayaWebhookPublicKeys.some(publicKey => {
    const verify = crypto.createVerify('RSA-SHA256');
    verify.update(verifyString);
    
    return verify.verify(publicKey, signature, 'hex')
  });
}

Webhook Timestamp Validation

const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; // 5-min tolerance
function verifyTimestamp(timestamp /* from X-Maya-Webhook-Timestamp */) {
  const currentTime = Date.now();
  const timeDifference = Math.abs(currentTime - timestamp);

  if (timeDifference > TIMESTAMP_TOLERANCE_MS) {
    console.warn(
      `Webhook timestamp outside tolerance window (diff: ${timeDifference}ms, max: ${TIMESTAMP_TOLERANCE_MS}ms)`
 
    );
    return false;
  }
  return true
}