Skip to main content

Verify webhook signatures

Verify a webhook before using its payload. Signature verification proves that the request was created by Paymennt and helps prevent replay attacks.

The verification must use the exact raw request body. Do not parse, pretty-print, decode, or otherwise alter the body before verifying it.

Signing headers​

Every webhook request includes these headers:

HeaderPurpose
message-idA stable identifier for the delivery. Replays of the same message keep this value.
message-timestampThe Unix timestamp, in seconds, for the delivery attempt.
message-signatureOne or more versioned, Base64-encoded signatures separated by spaces.

Get the endpoint's signing secret from Developer → Webhooks → Endpoints → Settings. Store it in your server-side secret manager; never expose it to a browser or mobile application.

Verify a signature manually​

Use your endpoint signing secret to calculate an HMAC-SHA256 digest of the following string:

{message-id}.{message-timestamp}.{raw-request-body}

The calculated digest must match one v1 value in the message-signature header. Compare signatures in constant time.

import crypto from 'node:crypto';

function verifyPaymenntWebhook({ rawBody, headers, signingSecret }) {
const messageId = headers['message-id'];
const timestamp = headers['message-timestamp'];
const signatureHeader = headers['message-signature'];

if (!messageId || !timestamp || !signatureHeader) {
return false;
}

// Signing secrets are Base64 encoded. Remove the optional identifier prefix
// before decoding when your stored value includes one.
const secretValue = signingSecret.replace(/^whsec_/, '');
const signedContent = `${messageId}.${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', Buffer.from(secretValue, 'base64'))
.update(signedContent)
.digest('base64');

return signatureHeader
.split(' ')
.filter((value) => value.startsWith('v1,'))
.map((value) => value.slice(3))
.some((candidate) => {
const expected = Buffer.from(expectedSignature);
const received = Buffer.from(candidate);
return expected.length === received.length
&& crypto.timingSafeEqual(expected, received);
});
}

Call this function with the raw body represented as the same UTF-8 string that was received on the wire. Parse the body as JSON only after it returns true.

Reject stale deliveries​

Check message-timestamp against your server clock before accepting the request. Use a short tolerance window appropriate to your service, such as five minutes. Reject a delivery outside that window even when its signature is valid.

Also record each message-id after successful processing. If the same ID is received again, acknowledge it safely without repeating the business action.

Common verification failures​

  • The raw body changed. JSON middleware, request parsers, or character conversion ran before verification.
  • The wrong secret is in use. Each endpoint has its own signing secret. Reveal or rotate the secret in that endpoint's settings.
  • Only one signature was checked. A signature header can contain multiple versioned values. Check every v1 value.
  • The timestamp is stale. Confirm your server has accurate time synchronization and use a bounded tolerance window.