Receive real-time notifications when customers, transactions, or bank accounts change status
Webhooks notify your application in real time whenever a status change occurs. Instead of polling the API, you receive an HTTP POST request to your endpoint with the updated data.
To configure webhooks, go to Developers > Webhooks in the Dashboard and add your endpoint URL. You can subscribe to specific event types or receive all events.
You must be a project admin or owner to create webhooks.
Each webhook request includes three headers for signature verification:
Header
Description
webhook-id
Unique message identifier
webhook-timestamp
Unix timestamp (seconds) of when the message was sent
webhook-signature
Base64-encoded signature(s), space-delimited and prefixed with version
To verify a webhook signature, you construct the signed content, compute the expected signature, and compare it with the header value.The signed content is created by concatenating the webhook-id, webhook-timestamp, and request body, separated by dots:
The signature is computed as a HMAC-SHA256 hash of the signed content using your webhook secret (base64-decoded) as the key, then base64-encoded.
Your webhook signing secret starts with whsec_. You must strip this prefix and base64-decode the remainder before using it as the HMAC key.
When you rotate your signing secret, the system continues signing messages with both the old and new secrets for 24 hours. This means the webhook-signature header may contain multiple signatures (e.g., v1,<old> v1,<new>). Your verification code should accept any valid signature from the list, which the examples below already handle.
TypeScript
JavaScript
Python
Go
import { createHmac, timingSafeEqual } from "crypto";function verifyWebhook( body: string, headers: Record<string, string>, secret: string): boolean { const msgId = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const signature = headers["webhook-signature"]; if (!msgId || !timestamp || !signature) { return false; } // Reject messages older than 5 minutes to prevent replay attacks const now = Math.floor(Date.now() / 1000); if (Math.abs(now - parseInt(timestamp)) > 300) { return false; } // Strip the whsec_ prefix and decode the secret const secretBytes = Buffer.from(secret.replace("whsec_", ""), "base64"); // Compute the expected signature const signedContent = `${msgId}.${timestamp}.${body}`; const expectedSignature = createHmac("sha256", secretBytes) .update(signedContent) .digest("base64"); // Compare against all provided signatures (there may be multiple) const signaturesV1 = signature .split(" ") .filter((s) => s.startsWith("v1,")) .map((s) => s.substring(3)); return signaturesV1.some((sig) => timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSignature)) );}
const { createHmac, timingSafeEqual } = require("crypto");function verifyWebhook(body, headers, secret) { const msgId = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const signature = headers["webhook-signature"]; if (!msgId || !timestamp || !signature) { return false; } // Reject messages older than 5 minutes to prevent replay attacks const now = Math.floor(Date.now() / 1000); if (Math.abs(now - parseInt(timestamp)) > 300) { return false; } // Strip the whsec_ prefix and decode the secret const secretBytes = Buffer.from(secret.replace("whsec_", ""), "base64"); // Compute the expected signature const signedContent = `${msgId}.${timestamp}.${body}`; const expectedSignature = createHmac("sha256", secretBytes) .update(signedContent) .digest("base64"); // Compare against all provided signatures (there may be multiple) const signaturesV1 = signature .split(" ") .filter((s) => s.startsWith("v1,")) .map((s) => s.substring(3)); return signaturesV1.some((sig) => timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSignature)) );}
import base64import hashlibimport hmacimport timedef verify_webhook( body: str, headers: dict[str, str], secret: str) -> bool: msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signature = headers.get("webhook-signature") if not msg_id or not timestamp or not signature: return False # Reject messages older than 5 minutes to prevent replay attacks now = int(time.time()) if abs(now - int(timestamp)) > 300: return False # Strip the whsec_ prefix and decode the secret secret_bytes = base64.b64decode(secret.removeprefix("whsec_")) # Compute the expected signature signed_content = f"{msg_id}.{timestamp}.{body}" expected_signature = base64.b64encode( hmac.new( secret_bytes, signed_content.encode(), hashlib.sha256 ).digest() ).decode() # Compare against all provided signatures (there may be multiple) signatures_v1 = [ s.removeprefix("v1,") for s in signature.split(" ") if s.startswith("v1,") ] return any( hmac.compare_digest(sig, expected_signature) for sig in signatures_v1 )
package webhookimport ( "crypto/hmac" "crypto/sha256" "encoding/base64" "fmt" "math" "net/http" "strconv" "strings" "time")func VerifyWebhook(body string, headers http.Header, secret string) bool { msgID := headers.Get("webhook-id") timestamp := headers.Get("webhook-timestamp") signature := headers.Get("webhook-signature") if msgID == "" || timestamp == "" || signature == "" { return false } // Reject messages older than 5 minutes to prevent replay attacks ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return false } now := time.Now().Unix() if math.Abs(float64(now-ts)) > 300 { return false } // Strip the whsec_ prefix and decode the secret secretBytes, err := base64.StdEncoding.DecodeString( strings.TrimPrefix(secret, "whsec_"), ) if err != nil { return false } // Compute the expected signature signedContent := fmt.Sprintf("%s.%s.%s", msgID, timestamp, body) mac := hmac.New(sha256.New, secretBytes) mac.Write([]byte(signedContent)) expectedSignature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) // Compare against all provided signatures (there may be multiple) for _, sig := range strings.Split(signature, " ") { if strings.HasPrefix(sig, "v1,") { if hmac.Equal( []byte(strings.TrimPrefix(sig, "v1,")), []byte(expectedSignature), ) { return true } } } return false}
If your infrastructure requires allowlisting specific IPs, add the following addresses. These IPs are shared across both sandbox and production environments: