Overview
Paid posts every transaction in your bank account direclty into Slack, Teams, email or your own webhooks.
Paid is a service built and hosted by Volley that makes it easy to connect your bank account to channels where your team works. You can use Paid to send notifications about debits or credits in your bank account to services like Slack, Teams, or your own systems via secure webhooks.
You don't have to be a Volley customer to use Paid, and you can set it up yourself in a few minutes by authorising connections to your bank and the channels you care about.
Paid runs on Volley's direct connections to New Zealand banks under the [Consumer Data Right (CDR)] (https://www.mbie.govt.nz/business-and-employment/business/consumer-data-right). Volley is an accredited requestor of account data and payments under the CDR and is ISO 27001 certified.
Paid's access to your bank account is strictly read-only (enforced by security rules at your bank). There's no way for money to leave your account as a result of using Paid.
How it works
- Connect your bank. Authorise Paid to read your account's transactions through open banking.
- Choose a destination. Pick Slack, Teams, email or a webhook endpoint for notifications.
- Get notified. Each time a transaction occurs on your account, Paid sends a notification to your destinations.
Paid refreshes your bank transactions on an adaptive schedule designed around New Zealand Settlement-Before-Interchange (SBI) interbank clearing windows. We'll let you know when we can see that funds are in your account, but this could be 1-2 hours after a payment has been sent to you.
Webhooks
Paid sends webhooks to your backend via an HTTP POST request. The format of a webhook for transactions received by Paid is below.
{
"type": "transaction.received",
"version": "1",
"data": {
"id": "txnd_8c4563239013f6c9bbef6d1fcbbe4a26efb38defd378b542ebd0944e3143eb39",
"direction": "credit",
"amount": {
"value": "1240.70 NZD",
"formatted": "$1240.70"
},
"description": "Payment from James McCann",
"pcr": {
"particulars": "INV-234",
"code": "",
"reference": ""
},
"counterparty": {
"name": "James McCann",
},
"bank_account": {
"id": "paidaccount_wh73OibCZNCY2hp8dJCVA",
"account_number": "01-1234-1234567-00",
"account_name": "Acme Ltd"
},
"booked_at": "2026-10-14T11:41:04+13:00",
"received_at": "2026-10-14T11:41:04+13:00"
}
}Paid webhooks have the following shape
Authentication
Webhook requests from Paid include a header for signature verification. This allows you to check the request legitimately came from Paid, preventing malicious use or spoofing.
Signatures are added as a header to the request to your webhook endpoint:
X-Paid-Signature: sha256=f7bc83f4305...You will be able to retrieve your webhook secret when creating each new webhook in the Paid. Your secret will only be displayed to you once, and you should securely store it within your own backend e.g. in secure environment variables or a runtime secrets manager. If you've lost access to your secret you will need to re-create your webhook.
Validating the signature
You can validate the signature on an incoming webhook request by recomputing it using the entire request body as the payload.
#!/bin/bash
PAYLOAD='<Webhook HTTP request body>'
BASE64_SECRET='<Your webhook secret>'
PROVIDED_SIG='sha256=... <signature from request header>'
CALCULATED=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$(echo -n "$BASE64_SECRET" | base64 -d)" | cut -d' ' -f2)
EXPECTED=$(echo "$PROVIDED_SIG" | cut -d'=' -f2)
echo "Calculated: $CALCULATED"
echo "Expected : $EXPECTED"
if [ "$CALCULATED" = "$EXPECTED" ]; then
echo "Valid"
else
echo "Invalid"
fi [HttpPost]
public IActionResult Webhook([FromBody] string payload)
{
string signature = Request.Headers["X-Volley-Signature"];
string secret = Configuration["WebhookSecret"];
if (WebhookValidator.ValidateWebhook(payload, secret, signature))
{
// Process webhook
return Ok();
}
return Unauthorized();
}
public static class WebhookValidator
{
public static bool ValidateWebhook(string payload, string base64Secret, string providedSignature)
{
try
{
byte[] secretKey = Convert.FromBase64String(base64Secret);
using (var hmac = new HMACSHA256(secretKey))
{
byte[] payloadBytes = Encoding.UTF8.GetBytes(payload);
byte[] hash = hmac.ComputeHash(payloadBytes);
string calculated = Convert.ToHexString(hash).ToLowerInvariant();
string expected = providedSignature.StartsWith("sha256=")
? providedSignature.Substring(7)
: providedSignature;
return calculated == expected;
}
}
catch
{
return false;
}
}
} func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
payload := string(body)
signature := r.Header.Get("X-Volley-Signature")
secret := os.Getenv("WEBHOOK_SECRET")
if validateWebhook(payload, secret, signature) {
processWebhook(payload)
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusUnauthorized)
}
func validateWebhook(payload, base64Secret, providedSignature string) bool {
secretKey, err := base64.StdEncoding.DecodeString(base64Secret)
if err != nil {
return false
}
h := hmac.New(sha256.New, secretKey)
h.Write([]byte(payload))
calculated := hex.EncodeToString(h.Sum(nil))
expected := providedSignature
if strings.HasPrefix(providedSignature, "sha256=") {
expected = providedSignature[7:]
}
return hmac.Equal([]byte(calculated), []byte(expected))
}
func processWebhook(payload string) {
println("Processing webhook:", payload)
} class WebhookController < ApplicationController
skip_before_action :verify_authenticity_token
def receive
payload = request.body.read
signature = request.headers['X-Volley-Signature']
secret = ENV['WEBHOOK_SECRET']
if WebhookValidator.validate(payload, secret, signature)
process_webhook(payload)
head :ok
else
head :unauthorized
end
end
private
def process_webhook(payload)
Rails.logger.info "Processing webhook: #{payload}"
end
end
class WebhookValidator
def self.validate(payload, base64_secret, provided_signature)
return false if base64_secret.nil? || provided_signature.nil?
begin
secret_key = Base64.decode64(base64_secret)
calculated = OpenSSL::HMAC.hexdigest('sha256', secret_key, payload)
expected = provided_signature.start_with?('sha256=') ?
provided_signature[7..-1] :
provided_signature
OpenSSL.fixed_length_secure_compare(calculated, expected)
rescue
false
end
end
end const express = require('express');
const crypto = require('crypto');
const app = express();
app.use('/webhook', express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const payload = req.body.toString();
const signature = req.headers['x-volley-signature'];
const secret = process.env.WEBHOOK_SECRET;
if (validateWebhook(payload, secret, signature)) {
processWebhook(payload);
res.status(200).send('OK');
} else {
res.status(401).send('Unauthorized');
}
});
function validateWebhook(payload, base64Secret, providedSignature) {
if (!base64Secret || !providedSignature) return false;
try {
const secretKey = Buffer.from(base64Secret, 'base64');
const calculated = crypto.createHmac('sha256', secretKey)
.update(payload, 'utf8')
.digest('hex');
const expected = providedSignature.startsWith('sha256=')
? providedSignature.substring(7)
: providedSignature;
return crypto.timingSafeEqual(
Buffer.from(calculated),
Buffer.from(expected)
);
} catch {
return false;
}
}
function processWebhook(payload) {
console.log('Processing webhook:', payload);
}Support
For help using Paid, or any other queries, please contact us directly via support@volley.nz.