Webhooks
Webhooks let you receive real-time notifications when events occur in your ModelRelay project. Instead of polling for changes, your server receives HTTP POST requests with event data.
Overview
When you configure a webhook, ModelRelay sends HTTP POST requests to your endpoint URL whenever subscribed events occur. Each request contains a JSON payload with event details and a cryptographic signature for verification.
sequenceDiagram
participant App as Your App
participant MR as ModelRelay
participant Endpoint as Your Webhook Endpoint
App->>MR: API Request (e.g., create customer)
MR-->>App: Response
MR->>Endpoint: POST webhook event
Note over Endpoint: Verify signature
Endpoint-->>MR: 200 OK
Note over Endpoint: Process event async
Common use cases:
- Customer lifecycle: Sync customer data to your database
- Usage monitoring: Alert when customers approach quota limits
- Billing integration: Update your system when subscriptions change
- Analytics: Log all API requests for analysis
Event Types
| Event | Description |
|---|---|
customer.created |
A new customer was created |
customer.updated |
Customer details were modified |
customer.deleted |
A customer was deleted |
usage.threshold_exceeded |
Customer exceeded a usage threshold (80%, 100%) |
request.completed |
An API request completed (success or failure) |
billing.subscription_changed |
Customer subscription status changed |
account.admission_blocked |
A request was refused because the project owner’s funds or free-credit cap could not cover it |
Configuring Webhooks
Via Dashboard
- Navigate to your project in the dashboard
- Select Webhooks from the sidebar
- Click Add Webhook
- Enter your endpoint URL (must be HTTPS)
- Select the events you want to receive
- Copy the signing secret for signature verification
Via API
Create a webhook programmatically:
curl -X POST https://api.modelrelay.ai/api/v1/projects/{project_id}/webhooks \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"endpoint_url": "https://your-app.com/webhooks/modelrelay",
"events": ["customer.created", "customer.updated", "usage.threshold_exceeded"],
"enabled": true
}'
Response:
{
"webhook": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"project_id": "123e4567-e89b-12d3-a456-426614174000",
"endpoint_url": "https://your-app.com/webhooks/modelrelay",
"signing_secret": "whsec_abc123...",
"enabled": true,
"events": ["customer.created", "customer.updated", "usage.threshold_exceeded"],
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}
}
Save the signing_secret securely—it’s only shown once at creation time.
Payload Format
Each webhook delivery sends a JSON payload with this structure:
{
"event_type": "customer.created",
"event_id": "evt_550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2025-01-15T10:30:00Z",
"project_id": "123e4567-e89b-12d3-a456-426614174000",
"data": {
"customer": {
"id": "789e0123-e89b-12d3-a456-426614174000",
"email": "user@example.com",
"tier_id": "456e7890-e89b-12d3-a456-426614174000",
"tier_code": "pro",
"external_id": "user_123",
"metadata": {},
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}
}
}
HTTP Headers
Each request includes these headers:
| Header | Description |
|---|---|
Content-Type |
application/json |
X-ModelRelay-Event-Type |
The event type (e.g., customer.created) |
X-ModelRelay-Event-ID |
Unique event identifier for idempotency |
X-ModelRelay-Signature |
HMAC SHA-256 signature for verification |
Event Payloads
Customer Events
customer.created, customer.updated, customer.deleted:
{
"event_type": "customer.created",
"event_id": "evt_...",
"timestamp": "2025-01-15T10:30:00Z",
"project_id": "...",
"data": {
"customer": {
"id": "789e0123-...",
"email": "user@example.com",
"tier_id": "456e7890-...",
"tier_code": "pro",
"external_id": "user_123",
"metadata": {"plan": "annual"},
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}
}
}
Usage Threshold Exceeded
usage.threshold_exceeded:
{
"event_type": "usage.threshold_exceeded",
"event_id": "evt_...",
"timestamp": "2025-01-15T10:30:00Z",
"project_id": "...",
"data": {
"customer_id": "789e0123-...",
"tier_id": "456e7890-...",
"tier_code": "pro",
"plan_type": "actions",
"unit": "actions",
"threshold_percent": 80,
"percentage_used": 85.5,
"used": 855,
"limit": 1000,
"remaining": 145,
"window_start": "2025-01-01T00:00:00Z",
"window_end": "2025-02-01T00:00:00Z"
}
}
Request Completed
request.completed:
{
"event_type": "request.completed",
"event_id": "evt_...",
"timestamp": "2025-01-15T10:30:00Z",
"project_id": "...",
"data": {
"request_id": "req_abc123",
"response_id": "resp_xyz789",
"model": "claude-sonnet-5",
"provider": "anthropic",
"status": 200,
"success": true,
"streaming": false,
"execution_origin": "provider",
"input_tokens": 150,
"cache_read_input_tokens": 100,
"cache_write_input_tokens": 25,
"output_tokens": 200,
"total_tokens": 350,
"duration_ms": 1250,
"customer_id": "789e0123-...",
"tier_id": "456e7890-...",
"tier_code": "pro"
}
}
execution_origin is provider for a provider call and response_cache for
an exact gateway cache replay. Cache read and write counts are disjoint subsets
of input_tokens; they are not added again to total_tokens.
Subscription Changed
billing.subscription_changed:
{
"event_type": "billing.subscription_changed",
"event_id": "evt_...",
"timestamp": "2025-01-15T10:30:00Z",
"project_id": "...",
"data": {
"customer_id": "789e0123-...",
"tier_id": "456e7890-...",
"tier_code": "pro",
"billing_provider": "stripe",
"billing_customer_id": "cus_abc123",
"billing_subscription_id": "sub_xyz789",
"subscription_status": "active",
"current_period_start": "2025-01-01T00:00:00Z",
"current_period_end": "2025-02-01T00:00:00Z"
}
}
Account Admission Blocked
account.admission_blocked is emitted when ModelRelay refuses a request before
provider dispatch because the project owner’s prepaid balance or free-credit
daily cap cannot cover it. Amounts are exact integer microcents.
{
"event_type": "account.admission_blocked",
"event_id": "alert_...",
"timestamp": "2026-08-11T10:30:00Z",
"project_id": "...",
"data": {
"account_id": "...",
"refusal_code": "PROVIDER_ACCOUNT_UNFUNDED",
"required_microcents": 525000000,
"available_microcents": 53000000,
"shortfall_microcents": 472000000
}
}
The other refusal code emitted by this event is FREE_CREDIT_DAILY_CAP. For
an unfunded account, required and available are the required and available
prepaid balances. For a daily-cap refusal, they are the projected daily spend
and the daily cap. available_microcents can be negative when active
reservations exceed the prepaid balance. ModelRelay deduplicates an active
condition for 15 minutes and retries failed webhook deliveries through the
normal delivery queue.
Signature Verification
Every webhook request includes an HMAC SHA-256 signature in the X-ModelRelay-Signature header. Always verify this signature to ensure the request came from ModelRelay.
The signature format is sha256={hex_digest}.
import { createHmac, timingSafeEqual } from "crypto";
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expectedSig = "sha256=" + createHmac("sha256", secret)
.update(payload)
.digest("hex");
// Use constant-time comparison to prevent timing attacks
try {
return timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSig)
);
} catch {
return false;
}
}
// Express.js example
app.post("/webhooks/modelrelay", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-modelrelay-signature"] as string;
const payload = req.body.toString();
if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET!)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(payload);
console.log(`Received ${event.event_type}: ${event.event_id}`);
// Process event...
res.status(200).send("OK");
});
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strings"
)
func verifyWebhookSignature(payload []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
// Use constant-time comparison
return hmac.Equal([]byte(signature), []byte(expected))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
signature := r.Header.Get("X-ModelRelay-Signature")
if !verifyWebhookSignature(payload, signature, os.Getenv("WEBHOOK_SECRET")) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
var event struct {
EventType string `json:"event_type"`
EventID string `json:"event_id"`
Data any `json:"data"`
}
json.Unmarshal(payload, &event)
log.Printf("Received %s: %s", event.EventType, event.EventID)
// Process event...
w.WriteHeader(http.StatusOK)
}
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
routing::post,
Router,
};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
fn verify_webhook_signature(payload: &[u8], signature: &str, secret: &str) -> bool {
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(payload);
let result = mac.finalize().into_bytes();
let expected = format!("sha256={}", hex::encode(result));
// Use constant-time comparison
signature.as_bytes().ct_eq(expected.as_bytes()).into()
}
async fn webhook_handler(
State(secret): State<String>,
headers: HeaderMap,
body: Bytes,
) -> StatusCode {
let signature = headers
.get("X-ModelRelay-Signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !verify_webhook_signature(&body, signature, &secret) {
return StatusCode::UNAUTHORIZED;
}
let event: serde_json::Value = serde_json::from_slice(&body)
.unwrap_or_default();
println!(
"Received {}: {}",
event["event_type"], event["event_id"]
);
// Process event...
StatusCode::OK
}
#[tokio::main]
async fn main() {
let secret = std::env::var("WEBHOOK_SECRET").expect("WEBHOOK_SECRET required");
let app = Router::new()
.route("/webhooks/modelrelay", post(webhook_handler))
.with_state(secret);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Retry Behavior
ModelRelay automatically retries failed webhook deliveries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 second |
| 3 | 2 seconds |
| 4 | 4 seconds |
| 5 | 8 seconds |
A delivery is considered successful if your endpoint returns a 2xx status code within 5 seconds.
A delivery is retried for:
- Timeout (no response within 5 seconds)
- Network errors
- 5xx status codes (server errors)
A delivery fails permanently for:
- 4xx status codes (client errors)
- 5 consecutive failed attempts
Auto-disable: After 5 consecutive failed delivery attempts, the webhook is automatically disabled to prevent further failures. Re-enable it in the dashboard after fixing your endpoint.
Idempotency
The same event may be delivered multiple times due to retries or network issues. Use the event_id field to deduplicate events:
const processedEvents = new Set<string>();
app.post("/webhooks/modelrelay", (req, res) => {
const event = req.body;
// Skip if already processed
if (processedEvents.has(event.event_id)) {
return res.status(200).send("Already processed");
}
// Process event...
processedEvents.add(event.event_id);
res.status(200).send("OK");
});
For production, store processed event IDs in a database with a TTL (e.g., 24 hours).
Best Practices
Respond Quickly
Return a 200 response immediately after verifying the signature. Process the event asynchronously to avoid timeouts:
app.post("/webhooks/modelrelay", async (req, res) => {
// Verify signature first
if (!verifySignature(req)) {
return res.status(401).send("Invalid signature");
}
// Acknowledge receipt immediately
res.status(200).send("OK");
// Process asynchronously
const event = req.body;
await queue.add("process-webhook", event);
});
Use HTTPS
Webhook endpoints must use HTTPS. ModelRelay rejects HTTP URLs to ensure payload security.
Rotate Secrets
If you suspect your signing secret is compromised:
- Update the webhook via API with
rotate_secret: true - Update your endpoint with the new secret
- Deploy before the old secret stops working
curl -X PUT https://api.modelrelay.ai/api/v1/projects/{project_id}/webhooks/{webhook_id} \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"endpoint_url": "https://your-app.com/webhooks/modelrelay",
"events": ["customer.created"],
"rotate_secret": true
}'
Monitor Delivery Status
Check webhook delivery history via the API:
curl https://api.modelrelay.ai/api/v1/projects/{project_id}/webhooks/{webhook_id}/events \
-H "Authorization: Bearer {access_token}"
Response includes delivery status, attempt count, response codes, and errors for debugging failed deliveries.
Next Steps
- Customer Billing - Manage customer subscriptions
- Tiers & Margins - Configure usage limits and pricing
- Customer Tokens - Issue tokens for your customers