Every outbound request from PayGrid includes an HMAC-SHA256 signature. Your handler must verify it on the raw request body before processing the event.
Credentials
PayGrid uses different credentials for API calls and webhook verification. Using the wrong one is the most common cause of signature failures.
| Credential | Used for | Webhook verification? |
|---|
api_key (sk_live_* / sk_test_*) | Authorization: Bearer on API calls | No |
api_secret | Shown once at registration | No — do not use this |
| Webhook signing key | HMAC-SHA256 for X-MeetPay-Signature | Yes |
api_secret is not your webhook signing key. API authentication uses only the api_key. If you verify webhooks with api_secret or api_key, signatures will never match.
The webhook signing key is a dedicated secret, separate from your API credentials. PayGrid provisions it for your account — contact PayGrid support if you do not have it. Store it in a secrets manager as e.g. PAYGRID_WEBHOOK_SIGNING_KEY.
Replay protection (±5 minutes) is recommended on your server — PayGrid does not reject stale timestamps on delivery; you enforce the window when verifying.
| Header | Value |
|---|
X-MeetPay-Signature | sha256=<hex> (HMAC-SHA256) |
X-MeetPay-Timestamp | Unix seconds |
X-MeetPay-Delivery-ID | Unique per delivery attempt — quote it in support requests |
Content-Type | application/json |
User-Agent | MeetPay-Webhook/1.0 |
Algorithm
signature_input = "{timestamp}.{raw_request_body}"
signature = HMAC-SHA256(webhook_signing_key, signature_input)
header_value = "sha256=" + hex(signature)
Verification steps
- Read
X-MeetPay-Timestamp from the request headers.
- Check the timestamp is within ±5 minutes of now (replay protection).
- Build the message:
"{timestamp}.{raw_body_bytes}".
- Compute
HMAC-SHA256(your_webhook_signing_key, message).
- Strip the
sha256= prefix from X-MeetPay-Signature.
- Compare using constant-time equality.
Always verify the raw body bytes, not a re-serialized JSON object. Whitespace differences will invalidate the signature.
Code examples
Node.js — verify helper
const crypto = require('crypto');
function verify(signatureHeader, timestamp, rawBody, signingKey) {
if (Math.abs(Date.now() / 1000 - parseInt(timestamp, 10)) > 300) {
return false;
}
const expected = crypto
.createHmac('sha256', signingKey)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const received = signatureHeader.replace('sha256=', '');
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}
Node.js — Express handler
Use a raw body parser on webhook routes — not global express.json():
const express = require('express');
const crypto = require('crypto');
const app = express();
app.post('/paygrid/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.headers['x-meetpay-timestamp'];
const signature = req.headers['x-meetpay-signature'];
if (!verify(signature, timestamp, req.body, process.env.PAYGRID_WEBHOOK_SIGNING_KEY)) {
return res.sendStatus(401);
}
res.sendStatus(200);
const event = JSON.parse(req.body.toString('utf8'));
processEvent(event).catch(console.error);
});
Python — FastAPI
import hmac
import hashlib
import time
def verify_meetpay_signature(
raw_body: bytes,
timestamp: str,
signature: str,
signing_key: str,
) -> bool:
if abs(int(time.time()) - int(timestamp)) > 300:
return False
message = f"{timestamp}.{raw_body.decode('utf-8')}"
expected = hmac.new(
signing_key.encode('utf-8'),
message.encode('utf-8'),
hashlib.sha256,
).hexdigest()
received = signature.removeprefix('sha256=')
return hmac.compare_digest(expected, received)
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
import json
app = FastAPI()
@app.post("/paygrid/webhooks")
async def meetpay_webhook(request: Request, background_tasks: BackgroundTasks):
raw_body = await request.body()
timestamp = request.headers.get("X-MeetPay-Timestamp", "")
signature = request.headers.get("X-MeetPay-Signature", "")
if not verify_meetpay_signature(
raw_body, timestamp, signature, SIGNING_KEY
):
raise HTTPException(status_code=401, detail="Invalid signature")
event = json.loads(raw_body)
background_tasks.add_task(process_event, event)
return {"received": True}
Common mistakes
| Mistake | Fix |
|---|
Using api_secret or api_key as the signing key | Use the webhook signing key from PayGrid support |
express.json() or request.json() before verify | Read raw bytes first; parse JSON only after verification |
JSON.stringify(parsedBody) for verification | Use the original raw bytes PayGrid sent |
| Comparing full header inconsistently | Strip sha256= from received; compare hex to hex |
| Strict replay window with clock skew | Allow ±5 minutes; sync server clocks |
Troubleshooting
If signature verification fails:
- Confirm you are using the webhook signing key, not
api_secret or api_key.
- Log
X-MeetPay-Delivery-ID and contact support if you need help tracing a delivery.
- Ensure your framework reads the raw body before any JSON middleware runs.
- Test with the dashboard Test webhook (Setup → Testing) using the same signing key.
- Recompute locally:
HMAC-SHA256(key, "{timestamp}.{exact_raw_body}") and compare to the header (strip sha256= from the header first).
- Confirm the signing key value with PayGrid support — it is not derivable from your
api_key or api_secret.
Next step: Handle events