Pave BankPave Bank

Signed Webhooks

Signed webhooks for verification

Overview

Pave secures webhook payloads using ECDSA (Elliptic Curve Digital Signature Algorithm) signatures.

Each webhook request includes a cryptographic signature in the Pave-Signature header that allows you to verify the authenticity and integrity of the payload.

Header Format

The Pave-Signature header contains a timestamp and signature in the following format:

t={timestamp},v1={signature}

Where:

  • timestamp: Unix timestamp of when the signature was generated
  • signature: Base64-encoded ECDSA signature of the signed payload

Signature Payload Construction

The signed payload is constructed by concatenating the raw response body with the timestamp:

{response_body}{timestamp}

Example:

  • Response body: {"transaction_id": "123456"}
  • Timestamp: 1234567890
  • Signed payload: {"transaction_id": "1234556"}1234567890

Important Implementation Notes

Raw Body Requirement

The response body must be processed as a raw buffer to ensure signature verification succeeds.

JSON parsing or other transformations can alter the byte representation of the payload, causing verification to fail

Example of problematic transformation:

// Original payload
{"transaction_id":"abc123"}

// After JSON parsing and re-serialization
// Added whitespace after colon would result in different byte representation
{"transaction_id": "abc123"}

Public Keys

Production Public Key

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAErvuXln33gpZG3fmrTZr0hpBcq3Dx
dcbhKPe4bkjH5LclzcvIHtwlCFZKdJ+HDdZnNr675zmvDvZ5nfs+nz+gZw==
-----END PUBLIC KEY-----

Staging Public Key

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEsYdA2Q2Abu6CTs9ncGvv3TVSujYu
BjwhvlTKBMPfcK3izCQPTRexasxkd1DcdMsgJu2hjYas7z4grPrryqEH0Q==
-----END PUBLIC KEY-----

Implementation Example (TypeScript/Node.js)

import express from 'express';
import bodyParser from 'body-parser';
import crypto from 'crypto';

const app = express();

// Middleware to preserve raw body 
app.use(bodyParser.json({
  type: "application/json",
  verify: (req, res, buf) => {
    (req as any).rawBody = buf;
  }
}));

app.post("/webhook-handler", async (req, res) => {
    const rawBodyBuf = (req as any).rawBody;
    const signatureHeader = req.headers["Pave-Signature"] as string;

    // key: Pave-Signature 
    // value: t={timestamp},v1={signature}
    const [timestampPart, sigPart] = signatureHeader.split(",").map(s => s.trim());
    const timestamp = timestampPart?.split("=")[1];
    const signature = sigPart?.split("=")[1];

    // reconstruct signed payload to verify 
    // signed payload in the following format: {body}{timestamp}
    const message = Buffer.concat([
        rawBodyBuf,
        Buffer.from(timestamp, "utf-8")
    ]); 

    const verifier = crypto.createVerify("SHA256");
    verifier.update(message);
    verifier.end();
   
    // Use appropriate public key based on environment 
    const PEM_PUBLIC_KEY = "";
    const b64Signature = Buffer.from(signature, 'base64');
    const isValid = verifier.verify(PEM_PUBLIC_KEY, b64Signature);

    if (!isValid) {
        // handle invalid state
    } else {
        // handle valid state
    }
})

On this page