Pave Bank

Webhooks

Webhooks for transaction and RFI updates, with signature verification

Overview

We send webhooks to your configured webhook URL when there is an update to a transaction or an RFI (Request for Information). Webhooks apply to both parent and sub-accounts.

To configure your webhook URL, see Update Webhook URL — only available in sandbox mode.

All webhooks are sent as POST requests with the following headers:

Content-Type: application/json
User-Agent: PaveBank-Webhook/1.0
Pave-Signature: t={timestamp},v1={signature}

Every webhook is signed. Verify the Pave-Signature header as described in Verifying Webhook Signatures.

Available Webhook Events

Transaction

A webhook is sent every time a transaction is created or transitions to a new status — whether it's money moving between your accounts or incoming/outgoing transfers. Each unique status of a transaction triggers at most one webhook, so you will not receive duplicates for the same status.

Body:

{
    "transaction_id": "transaction_b3mz0xppufvytxggtu7h09xb01"
}

Call Get Transaction to retrieve the complete transaction data, including its current status.

Request for Information (RFI)

See RFI Webhook for the payload format and examples.

Responding to Webhooks

Your webhook handler should respond with a 2xx status code to acknowledge receipt.

ResponseResult
2xxDelivered successfully
4xxPermanent failure, not retried
5xx, timeout, connection errorRetried with exponential backoff

Retry Policy

Failed deliveries are retried with exponential backoff (factor 2.0), starting with a 10-second delay between attempts and doubling after each failure (10s → 20s → 40s → 80s, etc.), capped at 10 minutes between attempts.

Retries continue for up to 72 hours, after which the webhook will no longer be attempted.

Verifying Webhook Signatures

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": "123456"}1234567890

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 Examples

package main

import (
    "crypto/ecdsa"
    "crypto/sha256"
    "crypto/x509"
    "encoding/base64"
    "encoding/pem"
    "io"
    "net/http"
    "strings"
)

// Use the appropriate public key based on environment
const pemPublicKey = `-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----`

func verifySignature(rawBody []byte, signatureHeader string) bool {
    // key: Pave-Signature
    // value: t={timestamp},v1={signature}
    var timestamp, signature string
    for _, part := range strings.Split(signatureHeader, ",") {
        key, value, found := strings.Cut(strings.TrimSpace(part), "=")
        if !found {
            continue
        }
        switch key {
        case "t":
            timestamp = value
        case "v1":
            signature = value
        }
    }
    if timestamp == "" || signature == "" {
        return false
    }

    block, _ := pem.Decode([]byte(pemPublicKey))
    if block == nil {
        return false
    }
    parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
    if err != nil {
        return false
    }
    publicKey, ok := parsed.(*ecdsa.PublicKey)
    if !ok {
        return false
    }

    decodedSignature, err := base64.StdEncoding.DecodeString(signature)
    if err != nil {
        return false
    }

    // reconstruct signed payload to verify
    // signed payload in the following format: {body}{timestamp}
    digest := sha256.Sum256(append(rawBody, []byte(timestamp)...))

    return ecdsa.VerifyASN1(publicKey, digest[:], decodedSignature)
}

func main() {
    http.HandleFunc("/webhook-handler", func(w http.ResponseWriter, r *http.Request) {
        // read the raw body before any JSON parsing
        rawBody, err := io.ReadAll(r.Body)
        if err != nil {
            w.WriteHeader(http.StatusBadRequest)
            return
        }

        if !verifySignature(rawBody, r.Header.Get("Pave-Signature")) {
            // handle invalid state
            w.WriteHeader(http.StatusUnauthorized)
            return
        }

        // handle valid state
        w.WriteHeader(http.StatusOK)
    })

    http.ListenAndServe(":8080", nil)
}

On this page