Skip to main content
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.
CredentialUsed forWebhook verification?
api_key (sk_live_* / sk_test_*)Authorization: Bearer on API callsNo
api_secretShown once at registrationNo — do not use this
Webhook signing keyHMAC-SHA256 for X-MeetPay-SignatureYes
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.

Request headers

HeaderValue
X-MeetPay-Signaturesha256=<hex> (HMAC-SHA256)
X-MeetPay-TimestampUnix seconds
X-MeetPay-Delivery-IDUnique per delivery attempt — quote it in support requests
Content-Typeapplication/json
User-AgentMeetPay-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

  1. Read X-MeetPay-Timestamp from the request headers.
  2. Check the timestamp is within ±5 minutes of now (replay protection).
  3. Build the message: "{timestamp}.{raw_body_bytes}".
  4. Compute HMAC-SHA256(your_webhook_signing_key, message).
  5. Strip the sha256= prefix from X-MeetPay-Signature.
  6. 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

MistakeFix
Using api_secret or api_key as the signing keyUse the webhook signing key from PayGrid support
express.json() or request.json() before verifyRead raw bytes first; parse JSON only after verification
JSON.stringify(parsedBody) for verificationUse the original raw bytes PayGrid sent
Comparing full header inconsistentlyStrip sha256= from received; compare hex to hex
Strict replay window with clock skewAllow ±5 minutes; sync server clocks

Troubleshooting

If signature verification fails:
  1. Confirm you are using the webhook signing key, not api_secret or api_key.
  2. Log X-MeetPay-Delivery-ID and contact support if you need help tracing a delivery.
  3. Ensure your framework reads the raw body before any JSON middleware runs.
  4. Test with the dashboard Test webhook (Setup → Testing) using the same signing key.
  5. Recompute locally: HMAC-SHA256(key, "{timestamp}.{exact_raw_body}") and compare to the header (strip sha256= from the header first).
  6. Confirm the signing key value with PayGrid support — it is not derivable from your api_key or api_secret.
Next step: Handle events