Cal.com integration
The Cal.com FlowApp demonstrates a full-featured integration with multiple actions, dynamic fetchers, and conditional schemas.App definition
IqraInfrastructure/Managers/FlowApp/Apps/CalCom/CalComApp.cs
using IqraCore.Interfaces.FlowApp;
using IqraCore.Interfaces.Integration;
using IqraInfrastructure.Managers.FlowApp.Apps.CalCom.Actions;
using IqraInfrastructure.Managers.FlowApp.Apps.CalCom.Fetchers;
using Microsoft.Extensions.Logging;
namespace IqraInfrastructure.Managers.FlowApp.Apps.CalCom
{
public class CalComApp : IFlowApp
{
public string AppKey => "cal_com";
public string Name => "Cal.com";
public string IconUrl => "https://cal.com/favicon.ico";
public string? IntegrationType => "cal_com";
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<CalComApp> _logger;
private const string BaseUrl = "https://api.cal.com/";
public IReadOnlyList<IFlowAction> Actions { get; }
public IReadOnlyList<IFlowDataFetcher> DataFetchers { get; }
public CalComApp(IHttpClientFactory httpClientFactory, ILogger<CalComApp> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
Actions = new List<IFlowAction>
{
new AddGuestsAction(this),
new BookMeetingAction(this),
new CancelBookingAction(this),
new GetAllBookingsAction(this),
new GetBookingAction(this),
new GetSlotsAction(this),
new MarkAbsentAction(this),
new RescheduleBookingAction(this)
};
DataFetchers = new List<IFlowDataFetcher>
{
new GetEventTypesByIdFetcher(this)
};
}
public HttpClient CreateClient()
{
var client = _httpClientFactory.CreateClient();
client.BaseAddress = new Uri(BaseUrl);
return client;
}
}
}
- Shared HttpClient factory: The
CreateClient()helper method is used by all actions and fetchers - Base URL constant: Centralized API endpoint configuration
- DI integration: Leverages
IHttpClientFactoryandILogger<T>
Action with multiple output ports
TheBookMeetingAction handles different API response codes with specific output ports:
IqraInfrastructure/Managers/FlowApp/Apps/CalCom/Actions/BookMeetingAction.cs
using IqraCore.Entities.FlowApp;
using IqraCore.Interfaces.FlowApp;
using IqraCore.Models.FlowApp.Integration;
using IqraInfrastructure.Managers.FlowApp.Apps.CalCom.Models;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace IqraInfrastructure.Managers.FlowApp.Apps.CalCom.Actions
{
public partial class BookMeetingAction : IFlowAction
{
private readonly CalComApp _app;
public BookMeetingAction(CalComApp app) => _app = app;
public string ActionKey => "BookMeeting";
public string Name => "Book a Meeting";
public string Description => "Schedules a new booking on Cal.com.";
public IReadOnlyList<ActionOutputPort> GetOutputPorts()
{
return new List<ActionOutputPort>
{
new ActionOutputPort { Key = "success", Label = "Success (201)" },
new ActionOutputPort { Key = "conflict", Label = "Slot Taken (409)" },
new ActionOutputPort { Key = "error", Label = "Error" }
};
}
public async Task<ActionExecutionResult> ExecuteAsync(
JsonElement input,
BusinessAppIntegrationDecryptedModel integration)
{
try
{
var apiKey = integration.DecryptedFields["ApiKey"];
var client = _app.CreateClient();
// Build request from validated input
var request = new CreateBookingRequest
{
Start = input.GetProperty("start").GetString() ?? string.Empty,
Attendee = new Attendee
{
Name = input.GetProperty("attendeeName").GetString() ?? "Guest",
Email = input.GetProperty("attendeeEmail").GetString() ?? "",
TimeZone = input.TryGetProperty("attendeeTimeZone", out var tz)
? tz.GetString() ?? "UTC"
: "UTC",
PhoneNumber = input.TryGetProperty("attendeePhone", out var ph)
? ph.GetString()
: null,
Language = "en"
},
Metadata = new Dictionary<string, string>()
};
// Add optional notes
if (input.TryGetProperty("notes", out var notes))
{
request.Metadata.Add("notes", notes.GetString() ?? "");
}
// Handle polymorphic targeting (ID vs Slug)
if (input.TryGetProperty("eventTypeId", out var id))
{
request.EventTypeId = id.GetInt32();
}
else if (input.TryGetProperty("teamSlug", out var team))
{
request.TeamSlug = team.GetString();
request.EventTypeSlug = input.GetProperty("eventTypeSlug").GetString();
}
else
{
request.Username = input.GetProperty("username").GetString();
request.EventTypeSlug = input.GetProperty("eventTypeSlug").GetString();
}
// Execute API call
var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/v2/bookings");
httpRequest.Content = new StringContent(
JsonSerializer.Serialize(request),
System.Text.Encoding.UTF8,
"application/json"
);
httpRequest.Headers.Add("Authorization", $"Bearer {apiKey}");
httpRequest.Headers.Add("cal-api-version", "2024-08-13");
var response = await client.SendAsync(httpRequest);
var responseContent = await response.Content.ReadAsStringAsync();
// Handle success
if (response.IsSuccessStatusCode)
{
var resultData = JsonSerializer.Deserialize<CalComResponse<BookingResponseData>>(responseContent);
return ActionExecutionResult.SuccessPort("success", resultData?.Data);
}
// Handle slot conflict (common in real-time booking)
if (response.StatusCode == HttpStatusCode.Conflict)
{
return ActionExecutionResult.SuccessPort(
"conflict",
new { message = "Time slot is no longer available." }
);
}
// General error
return ActionExecutionResult.Failure(
"API_ERROR",
$"Cal.com Error: {response.StatusCode} - {responseContent}"
);
}
catch (Exception ex)
{
return ActionExecutionResult.Failure("EXCEPTION", ex.Message);
}
}
}
}
- Multiple output ports:
success,conflict, anderrorallow the agent to handle different scenarios - Optional fields: Uses
TryGetPropertyfor optional inputs likenotesandattendeePhone - Polymorphic input handling: Supports three different booking methods (by ID, by username, or by team)
- Structured error responses: Returns specific error codes and messages
Schema with conditional fields
TheBookMeeting.json schema demonstrates oneOf for mutually exclusive field groups:
IqraInfrastructure/Managers/FlowApp/Apps/CalCom/Actions/BookMeeting.json
{
"type": "object",
"title": "Book a Meeting",
"required": ["start", "attendeeName", "attendeeEmail"],
"properties": {
"start": {
"type": "string",
"title": "Start Time",
"description": "ISO 8601 Date string (e.g. 2024-01-01T10:00:00Z)"
},
"attendeeName": {
"type": "string",
"title": "Attendee Name"
},
"attendeeEmail": {
"type": "string",
"title": "Attendee Email"
},
"attendeeTimeZone": {
"type": "string",
"title": "Attendee TimeZone",
"default": "UTC"
},
"attendeePhone": {
"type": "string",
"title": "Attendee Phone",
"description": "Required for SMS reminders"
},
"notes": {
"type": "string",
"title": "Additional Notes"
}
},
"oneOf": [
{
"title": "By Event ID",
"required": ["eventTypeId"],
"properties": {
"eventTypeId": {
"type": "integer",
"title": "Event Type ID",
"x-fetcher": "GetEventTypesById"
}
}
},
{
"title": "By User & Slug",
"required": ["username", "eventTypeSlug"],
"properties": {
"username": {
"type": "string",
"title": "Username"
},
"eventTypeSlug": {
"type": "string",
"title": "Event Type Slug"
}
}
},
{
"title": "By Team & Slug",
"required": ["teamSlug", "eventTypeSlug"],
"properties": {
"teamSlug": {
"type": "string",
"title": "Team Slug"
},
"eventTypeSlug": {
"type": "string",
"title": "Event Type Slug"
}
}
}
]
}
- Common base properties: Fields like
start,attendeeName,attendeeEmailare always required - Optional fields:
attendeeTimeZonehas a default value - oneOf variants: Three mutually exclusive ways to target an event type
- Dynamic fetcher:
x-fetcherconnects the field to a data source
Data fetcher with authentication
IqraInfrastructure/Managers/FlowApp/Apps/CalCom/Fetchers/GetEventTypesByIdFetcher.cs
using IqraCore.Entities.FlowApp;
using IqraCore.Interfaces.FlowApp;
using IqraCore.Models.FlowApp.Integration;
using IqraInfrastructure.Managers.FlowApp.Apps.CalCom.Models;
using System.Net.Http.Json;
using System.Text.Json;
namespace IqraInfrastructure.Managers.FlowApp.Apps.CalCom.Fetchers
{
public class GetEventTypesByIdFetcher : IFlowDataFetcher
{
private readonly CalComApp _app;
public GetEventTypesByIdFetcher(CalComApp app) { _app = app; }
public string FetcherKey => "GetEventTypesById";
public async Task<List<DynamicOption>> FetchOptionsAsync(
BusinessAppIntegrationDecryptedModel? integration,
JsonElement context)
{
if (integration == null) return new();
try
{
var apiKey = integration.DecryptedFields["ApiKey"];
var client = _app.CreateClient();
var httpRequest = new HttpRequestMessage(HttpMethod.Get, "/v2/event-types");
httpRequest.Headers.Add("Authorization", $"Bearer {apiKey}");
httpRequest.Headers.Add("cal-api-version", "2024-06-14");
var response = await client.SendAsync(httpRequest);
if (!response.IsSuccessStatusCode) return new();
var result = await response.Content.ReadFromJsonAsync<CalComResponse<List<EventTypeDto>>>();
return result?.Data?.Select(e => new DynamicOption
{
Label = $"{e.Title} ({e.Length}m)",
Value = e.Id,
Description = $"ID: {e.Id}"
}).ToList() ?? new();
}
catch { return new(); }
}
}
}
- Null check: Returns empty list if integration is not provided
- Error tolerance: Catches all exceptions and returns empty list (prevents UI errors)
- Rich labels: Combines title and duration for better UX (“30min Consultation (30m)”)
- API versioning: Uses version headers for API compatibility
Common patterns
Pattern: Handling optional fields
// Check if field exists before accessing
if (input.TryGetProperty("optionalField", out var field))
{
var value = field.GetString();
// Use the value
}
// Provide defaults for missing fields
var timezone = input.TryGetProperty("timeZone", out var tz)
? tz.GetString() ?? "UTC"
: "UTC";
Pattern: Returning structured data
// Return useful data for downstream nodes
return ActionExecutionResult.SuccessPort("success", new
{
bookingId = result.Id,
bookingUid = result.Uid,
confirmationUrl = result.Url,
startTime = result.StartTime,
endTime = result.EndTime,
attendees = result.Attendees.Select(a => new { a.Name, a.Email })
});
Pattern: Multiple authentication methods
public async Task<ActionExecutionResult> ExecuteAsync(
JsonElement input,
BusinessAppIntegrationDecryptedModel integration)
{
// Support both API key and OAuth token
string authHeader;
if (integration.DecryptedFields.ContainsKey("ApiKey"))
{
authHeader = $"Bearer {integration.DecryptedFields["ApiKey"]}";
}
else if (integration.DecryptedFields.ContainsKey("AccessToken"))
{
authHeader = $"Bearer {integration.DecryptedFields["AccessToken"]}";
}
else
{
return ActionExecutionResult.Failure("AUTH_ERROR", "No valid credentials found");
}
httpRequest.Headers.Add("Authorization", authHeader);
}
Pattern: Context-dependent fetcher
public async Task<List<DynamicOption>> FetchOptionsAsync(
BusinessAppIntegrationDecryptedModel? integration,
JsonElement context)
{
// Get parent selection from form state
if (context.TryGetProperty("teamId", out var teamIdElement))
{
var teamId = teamIdElement.GetInt32();
// Fetch only members of this team
var response = await client.GetAsync($"/teams/{teamId}/members");
// ...
}
else
{
// No team selected, return all members
var response = await client.GetAsync("/members");
// ...
}
}
Pattern: Rate limit handling
public async Task<ActionExecutionResult> ExecuteAsync(
JsonElement input,
BusinessAppIntegrationDecryptedModel integration)
{
var response = await client.SendAsync(httpRequest);
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(60);
return ActionExecutionResult.Failure(
"RATE_LIMIT",
$"Rate limit exceeded. Retry after {retryAfter.TotalSeconds} seconds."
);
}
// Continue with normal processing
}
Pattern: Pagination support
public async Task<ActionExecutionResult> ExecuteAsync(
JsonElement input,
BusinessAppIntegrationDecryptedModel integration)
{
var allItems = new List<Item>();
var page = 1;
var hasMore = true;
while (hasMore)
{
var response = await client.GetAsync($"/items?page={page}&limit=100");
var result = await response.Content.ReadFromJsonAsync<PaginatedResponse>();
allItems.AddRange(result.Items);
hasMore = result.HasMore;
page++;
// Safety limit
if (page > 100) break;
}
return ActionExecutionResult.SuccessPort("success", new { items = allItems, count = allItems.Count });
}
Pattern: Webhook registration
public class RegisterWebhookAction : IFlowAction
{
public string ActionKey => "RegisterWebhook";
public string Name => "Register Webhook";
public string Description => "Subscribe to real-time events.";
public async Task<ActionExecutionResult> ExecuteAsync(
JsonElement input,
BusinessAppIntegrationDecryptedModel integration)
{
var webhookUrl = input.GetProperty("webhookUrl").GetString();
var events = input.GetProperty("events").EnumerateArray()
.Select(e => e.GetString())
.ToList();
var request = new CreateWebhookRequest
{
Url = webhookUrl,
Events = events,
Active = true
};
var response = await client.PostAsJsonAsync("/webhooks", request);
var result = await response.Content.ReadFromJsonAsync<WebhookResponse>();
return ActionExecutionResult.SuccessPort("success", new
{
webhookId = result.Id,
secret = result.Secret // Store for signature verification
});
}
}
Testing FlowApps
Create unit tests for your actions:[Fact]
public async Task BookMeeting_ValidInput_ReturnsSuccess()
{
// Arrange
var httpClientFactory = CreateMockHttpClientFactory();
var logger = Mock.Of<ILogger<CalComApp>>();
var app = new CalComApp(httpClientFactory, logger);
var action = new BookMeetingAction(app);
var input = JsonSerializer.SerializeToElement(new
{
start = "2024-01-15T10:00:00Z",
attendeeName = "John Doe",
attendeeEmail = "[email protected]",
eventTypeId = 123
});
var integration = new BusinessAppIntegrationDecryptedModel
{
DecryptedFields = new Dictionary<string, string> { ["ApiKey"] = "test_key" }
};
// Act
var result = await action.ExecuteAsync(input, integration);
// Assert
Assert.True(result.Success);
Assert.Equal("success", result.OutputPortKey);
Assert.NotNull(result.Data);
}
Next steps
Overview
Review the FlowApp architecture and concepts
Creating FlowApps
Build your own FlowApp integration