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 |
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-4-5",
"provider": "anthropic",
"status": 200,
"success": true,
"streaming": false,
"input_tokens": 150,
"output_tokens": 200,
"total_tokens": 350,
"duration_ms": 1250,
"customer_id": "789e0123-...",
"tier_id": "456e7890-...",
"tier_code": "pro"
}
}
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"
}
}
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