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

  1. Navigate to your project in the dashboard
  2. Select Webhooks from the sidebar
  3. Click Add Webhook
  4. Enter your endpoint URL (must be HTTPS)
  5. Select the events you want to receive
  6. 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:

  1. Update the webhook via API with rotate_secret: true
  2. Update your endpoint with the new secret
  3. 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