Webhooks
Receive real-time event notifications from Vayu
Webhooks let Vayu push notifications to your server when billing events occur. You subscribe per event type with a callback URL, and Vayu sends a signed POST request whenever that event fires.
Subscribing
Endpoint: POST /webhook
{
"callbackUrl": "https://your-server.com/webhooks/vayu",
"eventType": "Overage"
}curl -X POST "https://connect.withvayu.com/webhook" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "x-api-key: $VAYU_CLIENT_ID" \
-d '{
"callbackUrl": "https://your-server.com/webhooks/vayu",
"eventType": "Overage"
}'import { Vayu } from 'vayu-ts';
const vayu = new Vayu(process.env.VAYU_API_KEY);
await vayu.webhooks.subscribe({
callbackUrl: 'https://your-server.com/webhooks/vayu',
eventType: 'Overage',
});from vayu_sdk import Vayu
import os
vayu = Vayu(api_key=os.environ["VAYU_API_KEY"])
vayu.webhooks.subscribe(
callback_url="https://your-server.com/webhooks/vayu",
event_type="Overage",
)import vayu "github.com/vayucode/vayu-sdks/go"
v := vayu.NewVayu(os.Getenv("VAYU_API_KEY"))
err := v.Webhooks.Subscribe(vayu.WebhookSubscribeRequest{
CallbackUrl: "https://your-server.com/webhooks/vayu",
EventType: "Overage",
})Available event types
| Event type | Description |
|---|---|
AnonymousCustomer | Event received for an unrecognized customer alias — auto-created as anonymous |
Overage | Customer exceeds their provisioned amount for a product |
UpcomingRenewal | A customer contract is approaching its renewal date |
ContractExpired | A customer contract has reached its end date |
InvoiceApproved | An invoice has been approved and is ready to send |
CustomerPortalLinkSent | A customer portal link was sent (or failed to send) |
NewCustomerWithoutContract | A new customer was created but has no contract |
MonthEndReport | The month-end report has been generated |
UnchargedEvents | Events exist that have not been counted against any meter |
TierCrossed | Customer usage has crossed into a new pricing tier |
CommitmentCrossed | Customer has crossed their committed usage threshold |
CommitmentUsageCrossed | Customer commitment usage has crossed a configured threshold percentage |
CurrencyCommitmentUsageCrossed | Customer monetary commitment usage has crossed a configured threshold percentage |
FinalTierExceeded | Customer usage has exceeded the final pricing tier |
InvoicePaymentStatusChanged | An invoice payment status has changed (e.g. paid, overdue) |
InvoiceSendFailed | An invoice failed to send to the customer |
CreditPoolDepleted | A customer credit pool balance has reached zero |
CreditPoolThresholdCrossed | A customer credit pool has crossed a configured usage threshold |
CreditPoolIncreased | Credits were added to a customer credit pool |
PaymentFailed | A payment attempt for an invoice failed |
CustomerCreated | A new customer was created |
ContractCreated | A new contract was created |
PingTest | A test event to verify your endpoint is reachable |
The event type is delivered in the X-Webhook-Type request header — the JSON body contains only the event's data fields, not a type property. Threshold-based events (CommitmentUsageCrossed, CurrencyCommitmentUsageCrossed, CreditPoolThresholdCrossed) accept optional threshold and recurringThreshold values when subscribing.
Webhook payloads
The type field is not part of the body. Read the event type from the X-Webhook-Type header and parse the body below accordingly. All date fields are ISO 8601 date-time strings.
AnonymousCustomer
{
"id": "cust_123456789",
"name": "Acme Corp",
"externalId": "ext_987654321",
"aliases": ["ext_987654321"]
}Overage
{
"date": "2026-06-20T12:00:00Z",
"overageAmount": 200,
"invoiceId": "inv_123456789",
"customerId": "cust_123456789",
"customerName": "Acme Corp",
"currency": "USD"
}UpcomingRenewal
{
"date": "2026-06-20T12:00:00Z",
"contractEndDate": "2026-07-01T00:00:00Z",
"contractName": "Acme Annual Plan",
"contractId": "contract_abc123",
"customerName": "Acme Corp",
"isTrial": false
}ContractExpired
{
"date": "2026-06-20T12:00:00Z",
"contractEndDate": "2026-06-20T00:00:00Z",
"contractName": "Acme Annual Plan",
"contractId": "contract_abc123",
"customerName": "Acme Corp",
"isTrial": false
}InvoiceApproved
{
"date": "2026-06-20T12:00:00Z",
"invoiceId": "inv_123456789"
}CustomerPortalLinkSent
{
"customerId": "cust_123456789",
"isSuccessful": true,
"email": "billing@acme.com",
"date": "2026-06-20T12:00:00Z"
}When sending fails, isSuccessful is false and an errorMessage field is included.
NewCustomerWithoutContract
{
"date": "2026-06-20T12:00:00Z",
"customerId": "cust_123456789",
"customerName": "Acme Corp"
}MonthEndReport
{
"date": "2026-06-20T12:00:00Z",
"reportId": "report_123456789"
}UnchargedEvents
{
"date": "2026-06-20T12:00:00Z",
"reportId": "report_123456789"
}TierCrossed
{
"date": "2026-06-20T12:00:00Z",
"customerId": "cust_123456789",
"contractId": "contract_abc123",
"customerName": "Acme Corp",
"contractName": "Acme Annual Plan",
"variantId": "variant_123"
}CommitmentCrossed
The payload is the affected product's consumption snapshot.
{
"productId": "prod_123456789",
"productName": "API Calls",
"provisionedAmount": 50000,
"consumedAmount": 50001,
"usagePercentage": 100,
"hasAccess": true,
"remainingAmount": 0,
"exceededAmount": 1
}CommitmentUsageCrossed
{
"date": "2026-06-20T12:00:00Z",
"customerId": "cust_123456789",
"threshold": 80,
"pctUsage": 80,
"productConsumption": {
"productId": "prod_123456789",
"productName": "API Calls",
"provisionedAmount": 50000,
"consumedAmount": 40000,
"usagePercentage": 80,
"hasAccess": true,
"remainingAmount": 10000
}
}CurrencyCommitmentUsageCrossed
{
"date": "2026-06-20T12:00:00Z",
"productId": "prod_123456789",
"productName": "API Calls",
"customerId": "cust_123456789",
"threshold": 80,
"pctUsage": 80,
"currency": "USD",
"committedAmount": 10000,
"consumedAmount": 8000,
"cycleStart": "2026-06-01T00:00:00Z"
}FinalTierExceeded
{
"date": "2026-06-20T12:00:00Z",
"customerId": "cust_123456789",
"contractId": "contract_abc123",
"customerName": "Acme Corp",
"contractName": "Acme Annual Plan",
"productId": "prod_123456789",
"productName": "API Calls",
"accumulatedUnitsValue": 100500
}InvoicePaymentStatusChanged
billingStatus is one of None, Paid, Rejected, PendingPayment, Overdue.
{
"invoiceId": "inv_123456789",
"billingStatus": "Paid",
"amountDue": 0,
"amountPaid": 1500,
"total": 1500,
"dueDate": "2026-06-15T00:00:00Z",
"paidAt": "2026-06-14T09:30:00Z",
"invoicePdfUrl": "https://connect.withvayu.com/invoices/inv_123456789.pdf"
}InvoiceSendFailed
{
"invoiceId": "inv_123456789",
"invoiceNumber": "INV-2026-0042",
"date": "2026-06-20T12:00:00Z"
}CreditPoolDepleted
creditKind is either Monetary or Unit.
{
"date": "2026-06-20T12:00:00Z",
"customerId": "cust_123456789",
"creditPoolId": "pool_123456789",
"creditTypeId": "credit_123456789",
"creditKind": "Monetary",
"balance": 0
}CreditPoolThresholdCrossed
{
"date": "2026-06-20T12:00:00Z",
"customerId": "cust_123456789",
"creditPoolId": "pool_123456789",
"creditTypeId": "credit_123456789",
"creditKind": "Monetary",
"threshold": 80,
"pctUsage": 80,
"balance": 200
}CreditPoolIncreased
{
"date": "2026-06-20T12:00:00Z",
"customerId": "cust_123456789",
"creditPoolId": "pool_123456789",
"creditTypeId": "credit_123456789",
"creditKind": "Monetary",
"grantedAmount": 1000,
"previousBalance": 200,
"balance": 1200
}PaymentFailed
{
"date": "2026-06-20T12:00:00Z",
"customerId": "cust_123456789",
"invoiceId": "inv_123456789",
"paymentProvider": "Stripe",
"amount": 1500,
"currency": "USD",
"failureReason": "card_declined"
}CustomerCreated
{
"date": "2026-06-20T12:00:00Z",
"customerId": "cust_123456789",
"name": "Acme Corp",
"externalId": "ext_987654321",
"aliases": ["ext_987654321"],
"integrationEntities": [
{
"integrationProvider": "Stripe",
"integrationExternalId": "cus_abc123",
"syncStatus": "Synced"
}
]
}ContractCreated
{
"date": "2026-06-20T12:00:00Z",
"contractId": "contract_abc123",
"customerId": "cust_123456789",
"contractName": "Acme Annual Plan",
"status": "Active",
"startDate": "2026-06-20T00:00:00Z",
"endDate": "2027-06-20T00:00:00Z",
"customer": {
"date": "2026-06-20T12:00:00Z",
"customerId": "cust_123456789",
"name": "Acme Corp",
"externalId": "ext_987654321",
"aliases": ["ext_987654321"],
"integrationEntities": []
}
}PingTest
Sent to verify your endpoint is reachable. The body carries no event-specific fields.
{}Handling webhook events
A minimal server that receives Vayu webhooks, routes by event type, and reads the payload:
import express from 'express';
import { Vayu } from 'vayu-ts';
const vayu = new Vayu(process.env.VAYU_API_KEY);
const app = express();
app.use(express.json());
// Register the webhook (run once)
// await vayu.webhooks.subscribe({
// callbackUrl: 'https://your-server.com/webhooks/vayu',
// eventType: 'Overage',
// });
app.post('/webhooks/vayu', (req, res) => {
const eventType = req.headers['x-webhook-type'];
const payload = req.body;
switch (eventType) {
case 'Overage':
console.log(`Overage of ${payload.overageAmount} ${payload.currency} for ${payload.customerName}`);
break;
case 'InvoiceApproved':
console.log(`Invoice ${payload.invoiceId} approved`);
break;
case 'TierCrossed':
console.log(`${payload.customerName} crossed a tier on ${payload.contractName}`);
break;
case 'AnonymousCustomer':
console.log(`New anonymous customer created: ${payload.externalId}`);
break;
default:
console.log(`Unhandled event: ${eventType}`);
}
res.sendStatus(200);
});
app.listen(3000, () => console.log('Listening on :3000'));from flask import Flask, request
from vayu_sdk import Vayu
import os
vayu = Vayu(api_key=os.environ["VAYU_API_KEY"])
app = Flask(__name__)
# Register the webhook (run once)
# vayu.webhooks.subscribe(
# callback_url="https://your-server.com/webhooks/vayu",
# event_type="Overage",
# )
@app.route('/webhooks/vayu', methods=['POST'])
def handle_webhook():
event_type = request.headers.get('X-Webhook-Type')
payload = request.get_json()
if event_type == 'Overage':
print(f"Overage of {payload['overageAmount']} {payload['currency']} for {payload['customerName']}")
elif event_type == 'InvoiceApproved':
print(f"Invoice {payload['invoiceId']} approved")
elif event_type == 'TierCrossed':
print(f"{payload['customerName']} crossed a tier on {payload['contractName']}")
elif event_type == 'AnonymousCustomer':
print(f"New anonymous customer created: {payload['externalId']}")
else:
print(f"Unhandled event: {event_type}")
return '', 200
if __name__ == '__main__':
app.run(port=3000)package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
vayu "github.com/vayucode/vayu-sdks/go"
)
// Register the webhook (run once)
// v := vayu.NewVayu(os.Getenv("VAYU_API_KEY"))
// v.Webhooks.Subscribe(vayu.WebhookSubscribeRequest{
// CallbackUrl: "https://your-server.com/webhooks/vayu",
// EventType: "Overage",
// })
func main() {
http.HandleFunc("/webhooks/vayu", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
eventType := r.Header.Get("X-Webhook-Type")
var payload map[string]interface{}
json.Unmarshal(body, &payload)
switch eventType {
case "Overage":
fmt.Printf("Overage of %v %v for %v\n",
payload["overageAmount"], payload["currency"], payload["customerName"])
case "InvoiceApproved":
fmt.Printf("Invoice %v approved\n", payload["invoiceId"])
case "TierCrossed":
fmt.Printf("%v crossed a tier on %v\n",
payload["customerName"], payload["contractName"])
case "AnonymousCustomer":
fmt.Printf("New anonymous customer created: %v\n", payload["externalId"])
default:
fmt.Printf("Unhandled event: %s\n", eventType)
}
w.WriteHeader(http.StatusOK)
})
fmt.Println("Listening on :3000")
http.ListenAndServe(":3000", nil)
}In production, always verify the webhook signature before processing the payload.
Webhook security
All Vayu webhook requests include headers for signature verification:
| Header | Description |
|---|---|
X-Webhook-Type | The event type (e.g. Overage) — use this to route the payload |
X-Timestamp | Unix timestamp (seconds) of when the request was sent |
X-Signature | Base64-encoded RSA-SHA256 signature of timestamp.JSON(payload) |
X-Signature-Version | Signature scheme version (currently v1) |
The signed message is: ${timestamp}.${JSON.stringify(payload)}
Only HTTPS callback URLs are supported.
Verifying signatures
The TypeScript SDK provides a built-in helper. Built-in verification for the Python and Go SDKs is coming soon — in the meantime, verify manually using Vayu's public key.
import { Vayu } from 'vayu-ts';
const vayu = new Vayu(process.env.VAYU_API_KEY);
app.post('/webhooks', async (req, res) => {
const payload = JSON.stringify(req.body);
const timestamp = Number(req.headers['x-timestamp']);
const signature = String(req.headers['x-signature']);
const isValid = vayu.webhooks.verifyWebhookSignature({
payload,
timestamp,
signature,
tolerance: 300, // optional — reject if older than 5 minutes (default)
});
if (!isValid) {
return res.sendStatus(401);
}
console.log('Received webhook:', req.headers['x-webhook-type']);
res.sendStatus(200);
});import json, time, base64, math
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from flask import Flask, request
# Load Vayu's public key (provided during onboarding)
with open("vayu-public.pem", "rb") as f:
public_key = serialization.load_pem_public_key(f.read())
TOLERANCE = 300 # 5 minutes
app = Flask(__name__)
@app.route('/webhooks', methods=['POST'])
def webhooks():
payload = request.get_data(as_text=True)
timestamp = int(request.headers.get('X-Timestamp', '0'))
signature = request.headers.get('X-Signature', '')
# Reject stale webhooks
if abs(time.time() - timestamp) > TOLERANCE:
return '', 401
# Reconstruct the signed message
message = f"{timestamp}.{json.dumps(json.loads(payload))}"
try:
public_key.verify(
base64.b64decode(signature),
message.encode(),
padding.PKCS1v15(),
hashes.SHA256(),
)
except Exception:
return '', 401
print('Received webhook:', request.headers.get('X-Webhook-Type'))
return '', 200import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"math"
"net/http"
"os"
"strconv"
"time"
)
// Load Vayu's public key (provided during onboarding)
var publicKey *rsa.PublicKey
func init() {
keyData, _ := os.ReadFile("vayu-public.pem")
block, _ := pem.Decode(keyData)
pub, _ := x509.ParsePKIXPublicKey(block.Bytes)
publicKey = pub.(*rsa.PublicKey)
}
const tolerance = 300 // 5 minutes
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
timestamp, _ := strconv.ParseInt(r.Header.Get("X-Timestamp"), 10, 64)
signature := r.Header.Get("X-Signature")
// Reject stale webhooks
if math.Abs(float64(time.Now().Unix()-timestamp)) > tolerance {
http.Error(w, "stale webhook", http.StatusUnauthorized)
return
}
// Reconstruct the signed message
message := fmt.Sprintf("%d.%s", timestamp, string(body))
hash := sha256.Sum256([]byte(message))
sigBytes, _ := base64.StdEncoding.DecodeString(signature)
err := rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hash[:], sigBytes)
if err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
fmt.Println("Received webhook:", r.Header.Get("X-Webhook-Type"))
w.WriteHeader(http.StatusOK)
}
