Need some help troubleshooting 403 Forbidden on webhook verification logic. I am provisioning a Lambda consumer via Pulumi TypeScript that validates the x-genesys-cloud-signature header, but the HMAC SHA256 comparison fails consistently despite the secret matching the OAuth client config.
typescript
const expected = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET).update(body).digest('hex');
if (expected !== headers['x-genesys-cloud-signature']) {
throw new Error('Invalid signature');
}
Is the body encoding different in the raw event vs the parsed JSON?
If you check the docs, they mention the signature is calculated over the raw request body, not a parsed JSON string. This distinction breaks most standard verification logic because serialization changes byte order or adds whitespace.
I see you are using body directly. Ensure body is the exact Buffer or string received from the HTTP event, before any JSON parsing. If you parse it first, the HMAC will never match.
Here is the corrected logic for Node.js:
const rawBody = event.body; // Must be raw string or buffer
const secret = process.env.WEBHOOK_SECRET;
const signature = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const expectedSignature = headers['x-genesys-cloud-signature'];
if (signature !== expectedSignature) {
throw new Error('Invalid signature');
}
Verify your Lambda handler is not auto-parsing the body. If event.body is null, check the API Gateway integration response settings. I hit this exact wall when moving from Python to TS. The secret must also match the OAuth client secret exactly, with no trailing newlines.
The best way to fix this is to ensure you are hashing the raw buffer. **** is correct. Pulumi deployments often parse JSON early. You must use the raw event body.
- Disable automatic JSON parsing in your API Gateway.
- Pass the
event.body as a string or buffer directly to HMAC.
| Requirement |
Setting |
| Body Parsing |
Disabled |
| Input Type |
Raw String/Buffer |
This aligns with how Open Messaging webhooks expect payloads. No extra fluff.
Have you tried checking if your API Gateway integration is set to AWS_PROXY or AWS? If you are using the legacy AWS integration type, the body is passed as a Base64-encoded string, which means event.body is not the raw bytes Genesys Cloud signed. You must decode it first. The previous suggestions about raw buffers are correct, but they assume the payload arrives in plain text. In many Pulumi-generated API Gateway configs, especially older templates, the requestTemplates map forces JSON parsing or encoding shifts that corrupt the original byte sequence before it hits your Lambda. You need to ensure the Integration resource has integrationType: 'AWS_PROXY' (or AWS_LAMBDA for Lambda Proxy Integration) so the raw POST body is preserved. If you cannot change the integration type, you must decode the Base64 string before hashing. Here is the robust verification logic that handles both cases:
import * as crypto from 'crypto';
function verifySignature(body: string | Buffer, signature: string, secret: string): boolean {
// Handle Base64 encoded body from API Gateway 'AWS' integration
const rawBody = Buffer.isBuffer(body)
? body
: Buffer.from(body, 'base64');
const hmac = crypto.createHmac('sha256', secret);
hmac.update(rawBody);
const digest = hmac.digest('hex');
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature));
}
This ensures that whether the body arrives as a plain string (Proxy Integration) or Base64 (Legacy Integration), you are hashing the exact bytes Genesys Cloud signed. Do not rely on JSON.stringify or parsed objects. The signature is strictly over the HTTP message body bytes.
- API Gateway Integration Type (
AWS_PROXY vs AWS)
- Base64 decoding of
event.body
crypto.timingSafeEqual for constant-time comparison
- Raw buffer handling in Node.js Lambdas
- Genesys Cloud Webhook Secret management in Pulumi
This issue stems from the API Gateway integration type handling the payload differently than expected. If you are using the legacy AWS integration type, event.body is Base64-encoded. Genesys Cloud signs the raw JSON bytes, not the encoded string. You must decode it before hashing.
Ensure you handle the isBase64Encoded flag explicitly.
Here is the corrected Node.js logic:
import * as crypto from 'crypto';
const verifySignature = (event: any, secret: string) => {
const signature = event.headers['x-genesys-cloud-signature'];
if (!signature) return false;
// Decode if Base64 encoded (common in AWS_PROXY or legacy AWS types)
const bodyBuffer = event.isBase64Encoded
? Buffer.from(event.body, 'base64')
: Buffer.from(event.body, 'utf-8');
const expected = crypto
.createHmac('sha256', secret)
.update(bodyBuffer)
.digest('hex');
return expected === signature;
};
I often see this break in Pulumi stacks where IntegrationType defaults to AWS. Switch to AWS_PROXY to get raw JSON directly, or use the decode logic above.