Skip to content

Filevine Webhook Integration

Configure your Experience API to receive webhooks from Filevine's legal case management platform.

Property Value
Goal Receive and process Filevine webhook events
Prerequisites Filevine account, completed Webhook Development setup
Time Estimate 15-30 minutes (after base setup)
Difficulty Intermediate

Overview

Filevine is a legal case management SaaS platform that sends webhook notifications when events occur (e.g., project created, document added, task completed).

Filevine Webhook Characteristics:

Aspect Details
Authentication JWT Bearer token from Filevine Identity Server
Identity Server (US) https://identity.filevine.com
JWT Audience filevine-v2-webhooks
Required Scope filevine-v2-webhooks-access
Signature JWT signature validated via OIDC discovery

Prerequisites

Complete Base Setup First

STOP - Before continuing, you MUST complete the Webhook Development Quick Start:

- [ ] Run `saif new saif-api-exp` with webhook support enabled
- [ ] Add `builder.AddWebhookTunnel(api)` to your AppHost
- [ ] Verify the webhook-tunnel appears in Aspire Dashboard

**Do not proceed** until your DevTunnel URL is working.

Once you have your DevTunnel URL, continue with the Filevine-specific configuration below.


Step 1: Configure Filevine Webhook

In Filevine's webhook settings, configure:

  • URL: https://{tunnel-url}/api/webhooks
  • Events: Select the events you want to receive

⚠️ Local Development Only: The DevTunnel URL changes when sessions expire. For production, use your APIM endpoint.


Step 2: Customize Webhook Handler

The template generates a generic WebhookEndpoints.cs. Customize it to handle Filevine-specific headers and payload structure:

// src/YourApp/Endpoints/WebhookEndpoints.cs

public static class WebhookEndpoints
{
    public static IEndpointRouteBuilder MapWebhookEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/webhooks")
            .WithTags("Webhooks");

        group.MapPost("/", HandleFilevineWebhook)
            .WithName("ReceiveFilevineWebhook")
            .WithSummary("Receive Filevine webhook events");

        return app;
    }

    private static async Task<IResult> HandleFilevineWebhook(
        [FromHeader(Name = "x-fv-messageid")] string? messageId,
        [FromHeader(Name = "x-fv-timestamp")] string? timestamp,
        HttpRequest request,
        ILogger<FilevineWebhookResponse> logger,
        CancellationToken cancellationToken)
    {
        using var reader = new StreamReader(request.Body);
        var rawBody = await reader.ReadToEndAsync(cancellationToken);

        var payload = JsonSerializer.Deserialize<FilevineWebhookPayload>(rawBody,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

        logger.LogInformation(
            "Filevine {Object}.{Event} received. NoteId: {NoteId}, ProjectId: {ProjectId}",
            payload?.Object, payload?.Event, payload?.ObjectId?.NoteId, payload?.ProjectId);

        // TODO: Implement idempotency check using messageId
        // TODO: Route to handlers based on Object and Event type

        return Results.Ok(new { received = true, messageId });
    }
}

Register the Endpoint

Add the endpoint mapping in Program.cs:

// src/YourApp/Program.cs

var app = builder.Build();

app.MapGet("/", () => new { Message = "Hello World!", ServerTime = DateTime.UtcNow });

// Register Filevine webhook endpoints
app.MapWebhookEndpoints();

app
    .UseServiceDefaults()
    .UseJwtBearerServices();

app.Run();

If MapWebhookEndpoints() isn't recognized, add the namespace to GlobalUsings.cs:

// src/YourApp/GlobalUsings.cs

global using YourApp.Endpoints;

Required Step

Without app.MapWebhookEndpoints(), the /api/webhooks route won't exist at runtime and requests will return 404.

Filevine Headers

Header Description
Authorization Bearer {JWT} - Validated by APIM in production
x-fv-messageid Unique message ID for idempotency
x-fv-timestamp ISO timestamp (e.g., 2024-01-15T10:30:00.000Z)

Step 3: Define Webhook Endpoint in TypeSpec

Define the webhook endpoint contract in your TypeSpec file to generate the OpenAPI specification:

// src/YourApp.TypeSpec/main.tsp

import "@typespec/http";
import "@typespec/rest";
import "@typespec/openapi";
import "@typespec/openapi3";
import "@typespec/versioning";
import "@saif/platform-typespec";

using TypeSpec.Http;
using TypeSpec.Rest;
using TypeSpec.OpenAPI;
using TypeSpec.Versioning;
using SAIF.Platform;

@service(#{ title: "YourApp Experience API" })
@versioned(Versions)
namespace YourApp;

enum Versions {
  v1,
}

// Filevine webhook endpoint
@route("/api/webhooks")
@added(Versions.v1)
interface Webhooks {
  @post
  @summary("Receive Filevine webhook events")
  @extension("x-webhook-provider", "filevine")
  receiveWebhook(
    @header("x-fv-messageid") messageId?: string,
    @header("x-fv-timestamp") timestamp?: string,
    @body payload: FilevineWebhookPayload,
  ): FilevineWebhookResponse;
}

// Filevine webhook payload models
// See: https://developer.filevine.io/docs/v2-us/62724ee7c344d-note-created
@added(Versions.v1)
model FilevineWebhookPayload {
  @doc("Object type (e.g., 'Note')")
  Object?: string;

  @doc("Event type (e.g., 'Created', 'Updated')")
  Event?: string;

  @doc("Unix timestamp of the event")
  Timestamp?: int64;

  @doc("The org (firm) ID")
  OrgId: int32;

  @doc("The project (case) ID")
  ProjectId?: int32;

  @doc("The user ID who triggered the event")
  UserId?: int32;

  @doc("The user type (e.g., 'Employee', 'Customer')")
  UserType?: string;

  @doc("Object-specific ID container")
  ObjectId?: FilevineObjectId;
}

@added(Versions.v1)
model FilevineObjectId {
  @doc("Note ID (present for Note events)")
  NoteId?: int32;
}

@added(Versions.v1)
model FilevineWebhookResponse {
  @doc("Whether the webhook was received successfully")
  received: boolean;

  @doc("The message ID echoed back")
  messageId?: string;

  @doc("The object type processed")
  objectType?: string;

  @doc("The event type processed")
  eventType?: string;

  @doc("The timestamp echoed back")
  timestamp?: string;

  @doc("The note ID if applicable")
  noteId?: int32;

  @doc("The project ID if applicable")
  projectId?: int32;

  @doc("The org ID")
  orgId?: int32;
}

Build the OpenAPI Specification

After defining your TypeSpec, build the OpenAPI file:

cd src/YourApp.TypeSpec
npm install
npm run build

This generates the OpenAPI specification at infra/api/openapi/openapi.v1.yaml, which is used by:

  • APIM - For API definition and policy enforcement
  • Kiota - For generating typed API clients
  • Documentation - For API reference docs

Step 4: Typed Payload Models (Optional)

Create typed models that match the Filevine webhook payload structure:

// src/YourApp/Models/FilevineModels.cs

/// <summary>
/// Filevine webhook payload structure.
/// All Filevine webhooks follow this format with Object/Event identifying the type.
/// </summary>
public record FilevineWebhookPayload
{
    /// <summary>Object type (e.g., "Note", "Project", "Document")</summary>
    public string? Object { get; init; }

    /// <summary>Event type (e.g., "Created", "Updated")</summary>
    public string? Event { get; init; }

    /// <summary>Unix timestamp of the event</summary>
    public long? Timestamp { get; init; }

    /// <summary>The org (firm) ID</summary>
    public int OrgId { get; init; }

    /// <summary>The project (case) ID</summary>
    public int? ProjectId { get; init; }

    /// <summary>The user ID who triggered the event</summary>
    public int? UserId { get; init; }

    /// <summary>The user type (e.g., "Employee", "Customer")</summary>
    public string? UserType { get; init; }

    /// <summary>Object-specific ID container</summary>
    public FilevineObjectId? ObjectId { get; init; }
}

/// <summary>
/// Container for object-specific IDs. Only relevant fields are populated per event type.
/// </summary>
public record FilevineObjectId
{
    public int? NoteId { get; init; }
    public int? DocumentId { get; init; }
    public int? ContactId { get; init; }
    // Add other ID fields as needed for different event types
}

/// <summary>Known Filevine object types for pattern matching.</summary>
public static class FilevineObjectTypes
{
    public const string Note = "Note";
    public const string Project = "Project";
    public const string Document = "Document";
    public const string Contact = "Contact";
}

/// <summary>Known Filevine event types for pattern matching.</summary>
public static class FilevineEventTypes
{
    public const string Created = "Created";
    public const string Updated = "Updated";
    public const string Deleted = "Deleted";
}

Pattern Matching

Use C# pattern matching with the constants for clean event handling: csharp var action = (payload.Object, payload.Event) switch { (FilevineObjectTypes.Note, FilevineEventTypes.Created) => "Note Created", (FilevineObjectTypes.Note, FilevineEventTypes.Updated) => "Note Updated", _ => "Unknown event" };


Step 5: Production Terraform Configuration

Enable Filevine Webhook Policy

In your Experience API's vars.yml, add/change the api_policy_type setting:

# infra/web/vars.yml

application_name: your-app-name
application_type: Api
api_type: Experience
owner: YourTeam
project_id: your-project-id

# Enable Filevine webhook JWT validation
api_policy_type: filevine

What This Does:

When api_policy_type = "filevine":

  1. APIM Policy - Uses api_policies_filevine instead of standard Okta/Entra routing
  2. JWT Validation - Validates Filevine JWTs using OIDC discovery from identity.filevine.com
  3. Header Override - Sets x-forward-tenant-origin: corp for downstream API calls

Deploy

Commit your changes and push to trigger the Azure DevOps pipeline (main automatically).


Step 6: Configure Filevine Production Webhook

After deployment, configure Filevine with your APIM endpoint:

https://{your-apim}.azure-api.net/exp-{app-name}/api/webhooks

Calling Downstream APIs

After receiving a Filevine webhook, your Experience API typically calls Process/System APIs. Since webhooks have no user context, use client credentials (app-to-app) authentication.

Step 1: Add KiotaReference

Add a reference to the downstream API's OpenAPI spec in your .csproj:

<ItemGroup>
  <KiotaReference Include="ProcessApiClient" OpenApi="https://openapi.saif.com/it-api-prc-cases/test/openapi.v1.yaml">
    <NamespaceName>YourApp.Clients.ProcessApi</NamespaceName>
  </KiotaReference>
</ItemGroup>

Step 2: Configure the Process API Authorization

In the Process API's infra/auth/corp/config.yml, authorize the Experience API to call it:

# infra/auth/corp/config.yml (Process API)
authorized_apps:
  - project_id: it-api-exp-yourapp  # The calling Experience API's project ID
    app_roles:
      - App.Read                     # App role(s) to grant
    scopes: user_impersonation       # Required

!!! info "Understanding scopes vs app_roles" - scopes: user_impersonation - Required for the pre-authorization to work - app_roles: [App.Read] - The actual permission granted to the calling app's service principal

For client credentials flow, the token will contain `"roles": ["App.Read"]` (not scopes). The Process API validates this role in its `@useAuth(Roles<["App.Read"]>)` TypeSpec decorator.

Step 3: Configure the Client

In the Experience API's Program.cs, configure the client with ClientCredentialsTokenProvider:

// Configure Kiota client for downstream Process API (no user context)
builder.ConfigureHttpClient<ProcessApiClient, ClientCredentialsTokenProvider>(
    "it-api-proc-cases",
    options => options.Scopes = [".default"]);

Step 4: Use the Client

Inject and use the client in your webhook handler:

public class FilevineWebhookHandler
{
    private readonly ProcessApiClient _processApi;

    public FilevineWebhookHandler(ProcessApiClient processApi)
    {
        _processApi = processApi;
    }

    public async Task HandleProjectCreated(FilevineProject project)
    {
        // This call goes through APIM with Entra client credentials
        await _processApi.Cases.PostAsync(new CreateCaseRequest
        {
            ExternalId = project.ProjectId.ToString(),
            Name = project.ProjectName
        });
    }
}

💡 The APIM policy sets x-forward-tenant-origin: corp, so downstream calls automatically use Entra authentication.

See Calling APIs for more details on ClientCredentialsTokenProvider.


Three-Tier Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│ Filevine Webhook → Experience API → Process API → System API                │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   Filevine ──► APIM ──► Experience API                                      │
│                 │            │                                              │
│            Filevine JWT      │                                              │
│            Validation        │                                              │
│                              ▼                                              │
│                         APIM ──► Process API                                │
│                          │                                                  │
│                     Entra JWT                                               │
│                     (corp header)                                           │
│                              ▼                                              │
│                         APIM ──► System API                                 │
│                          │                                                  │
│                     Entra JWT                                               │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Troubleshooting

401 Unauthorized in APIM

Cause: Invalid or missing Filevine JWT

Debug:

  1. Check APIM returns error headers (ErrorSource, ErrorReason, ErrorMessage)
  2. Verify the JWT in jwt.io
  3. Check issuer, audience, and scope claims

Webhook Not Reaching Your API

  1. Check APIM logs in Application Insights
  2. Verify the webhook URL in Filevine settings
  3. Ensure Front Door is routing correctly

DevTunnel URL Changed

Cause: The DevTunnel URL includes the port number. If you change the API port in launchSettings.json or modify endpoint configuration in ApiResourceBuilder.g.cs, a new tunnel URL is generated.

Solution:

  1. Check the new tunnel URL in the Aspire Dashboard under the "webhook-tunnel" resource
  2. Or run devtunnel list and devtunnel show <tunnel-id> to find the new URL
  3. Update the webhook URL in Filevine settings to match

Example: Port change from 224435001 changes URL from:

https://abc123-22443.usw2.devtunnels.ms/api/webhooks

to:

https://abc123-5001.usw2.devtunnels.ms/api/webhooks

Downstream API Calls Failing (403)

Filevine-specific cause:

  • Missing x-forward-tenant-origin: corp header - Ensure api_policy_type = "filevine" in your Experience API's vars.yml

General causes: See Webhook Troubleshooting for common 403 causes including missing app role assignments and package version mismatches.


Common Pitfalls

TypeSpec Error Response Types

When defining API operations that return error responses, use the SAIF Platform models, not the bare TypeSpec.Http responses:

// ✅ Correct - Uses SAIF.Platform models with ProblemDetails body
createNote(@body request: CreateNoteRequest): CreateNoteResponse | BadRequest;
getNote(@path noteId: int32): Note | NotFound;

// ❌ Wrong - Uses TypeSpec.Http responses without body schema
createNote(@body request: CreateNoteRequest): CreateNoteResponse | BadRequestResponse;
getNote(@path noteId: int32): Note | NotFoundResponse;

Why this matters: BadRequestResponse from @typespec/http generates a 400 response without a body schema. Kiota won't generate error mapping code, causing "no error factory is registered for this code" errors at runtime.

ProblemDetails Namespace Conflict

When Kiota generates a ProblemDetails model, it can conflict with Microsoft.AspNetCore.Mvc.ProblemDetails:

// Add alias to resolve ambiguity
using MvcProblemDetails = Microsoft.AspNetCore.Mvc.ProblemDetails;

// Then use the alias in your endpoint signatures
private static async Task<Results<Ok<Response>, BadRequest<MvcProblemDetails>>> HandleWebhook(...)

References