CampaignFlow Client
An Elixir client library for the Campaign Flow API, built with the Req HTTP client.
⚠️ AI Generated! ⚠️
Full disclosure, this repo was almost completely generated by Claude Code. Use at your own risk.
Features
- Shared Token Manager - OAuth2 tokens managed by GenServer processes and shared across your application
- Multi-Environment Support - Use both production and test environments simultaneously
- Automatic Token Refresh - Tokens refreshed automatically with race-condition-free coordination
- Simple API - No need to thread updated client through your code
- Full API Coverage - Complete coverage for all Campaign Flow endpoints
- Type-Safe - Function signatures with
@specfor compile-time checking - Comprehensive Error Handling - Structured error types for robust error handling
- Embedded Mock Server - Optional Plug-based mock API you can mount in your app for local development and end-to-end tests
Installation
Add campaign_flow to your list of dependencies in mix.exs:
def deps do
[
{:campaign_flow, "~> 2.1.0"}
]
endConfiguration
The client uses application-level configuration where credentials are configured globally and shared across your entire application. Each environment (production, test, etc.) has its own dedicated TokenManager process.
Basic Configuration
Configure environments in your config/config.exs:
config :campaign_flow,
environments: [
prod: [
base_url: "https://app.campaignflow.com.au/api/v2",
client_id: System.get_env("CAMPAIGNFLOW_PROD_CLIENT_ID"),
client_secret: System.get_env("CAMPAIGNFLOW_PROD_CLIENT_SECRET")
],
test: [
base_url: "https://test.campaignflow.com.au/api/v2",
client_id: System.get_env("CAMPAIGNFLOW_TEST_CLIENT_ID"),
client_secret: System.get_env("CAMPAIGNFLOW_TEST_CLIENT_SECRET")
]
]Runtime Configuration
For production deployments, use config/runtime.exs:
import Config
if config_env() == :prod do
config :campaign_flow,
environments: [
prod: [
base_url: "https://app.campaignflow.com.au/api/v2",
client_id: System.fetch_env!("CAMPAIGNFLOW_PROD_CLIENT_ID"),
client_secret: System.fetch_env!("CAMPAIGNFLOW_PROD_CLIENT_SECRET")
]
]
endEnvironment Variables
Set the following environment variables:
# Production credentials
export CAMPAIGNFLOW_PROD_CLIENT_ID="your_prod_client_id"
export CAMPAIGNFLOW_PROD_CLIENT_SECRET="your_prod_client_secret"
# Test environment credentials (if needed)
export CAMPAIGNFLOW_TEST_CLIENT_ID="your_test_client_id"
export CAMPAIGNFLOW_TEST_CLIENT_SECRET="your_test_client_secret"Multiple Environments
You can configure and use multiple environments simultaneously:
# Configure both prod and test
config :campaign_flow,
environments: [
prod: [
base_url: "https://app.campaignflow.com.au/api/v2",
client_id: System.get_env("CAMPAIGNFLOW_PROD_CLIENT_ID"),
client_secret: System.get_env("CAMPAIGNFLOW_PROD_CLIENT_SECRET")
],
test: [
base_url: "https://test.campaignflow.com.au/api/v2",
client_id: System.get_env("CAMPAIGNFLOW_TEST_CLIENT_ID"),
client_secret: System.get_env("CAMPAIGNFLOW_TEST_CLIENT_SECRET")
]
]
# Use them simultaneously in your application
prod_client = CampaignFlow.Client.new(environment: :prod)
test_client = CampaignFlow.Client.new(environment: :test)
{:ok, prod_campaigns} = CampaignFlow.Client.Campaigns.list(prod_client)
{:ok, test_campaigns} = CampaignFlow.Client.Campaigns.list(test_client)Usage
Creating a Client
Clients are lightweight structs that reference a configured environment. Credentials are stored in the application config, not in the client:
# Create a client for the production environment
client = CampaignFlow.Client.new(environment: :prod)
# Create a client for the test environment
client = CampaignFlow.Client.new(environment: :test)
# Or use the convenience function for test environment
client = CampaignFlow.Client.test()
# You can also override the base URL if needed
client = CampaignFlow.Client.new(
environment: :prod,
base_url: "https://custom.example.com/api/v2"
)Campaigns
# List campaigns
{:ok, campaigns} = CampaignFlow.Client.Campaigns.list(client)
# Get a specific campaign
{:ok, campaign} = CampaignFlow.Client.Campaigns.get(client, 123)
# Create a new campaign
{:ok, campaign} = CampaignFlow.Client.Campaigns.create(client, %{
name: "Summer Campaign 2024",
agency_id: 1,
property_id: 2
})
# Update a campaign
{:ok, campaign} = CampaignFlow.Client.Campaigns.update(client, 123, %{
name: "Updated Campaign Name"
})
# Add a comment to a campaign
{:ok, response} = CampaignFlow.Client.Campaigns.add_comment(client, 123, %{
comment: "Campaign approved by client"
})
# Set campaign status
{:ok, response} = CampaignFlow.Client.Campaigns.set_status(client, 123, %{
status: "approved"
})
# Send approved email
{:ok, response} = CampaignFlow.Client.Campaigns.send_approved_email(client, 123)Campaign Vendors
# List campaign vendors
{:ok, vendors} = CampaignFlow.Client.Campaigns.list_vendors(client, 123)
# Get a specific vendor
{:ok, vendor} = CampaignFlow.Client.Campaigns.get_vendor(client, 123, 456)
# Add a vendor to a campaign
{:ok, vendor} = CampaignFlow.Client.Campaigns.add_vendor(client, 123, %{
vendor_id: 456
})
# Update a campaign vendor
{:ok, vendor} = CampaignFlow.Client.Campaigns.update_vendor(client, 123, 456, %{
status: "approved"
})
# Remove a vendor from a campaign
{:ok, response} = CampaignFlow.Client.Campaigns.remove_vendor(client, 123, 456)
# Send campaign to vendor
{:ok, response} = CampaignFlow.Client.Campaigns.send_campaign_to_vendor(client, 123, 456)
# Verify vendor contact
{:ok, response} = CampaignFlow.Client.Campaigns.verify_vendor_contact(client, 123, 456)Agencies
# List agencies
{:ok, agencies} = CampaignFlow.Client.Agencies.list(client)
# Get a specific agency
{:ok, agency} = CampaignFlow.Client.Agencies.get(client, 123)Invoices
# List invoices
{:ok, invoices} = CampaignFlow.Client.Invoices.list(client)
# Get a specific invoice
{:ok, invoice} = CampaignFlow.Client.Invoices.get(client, 123)
# Create an invoice
{:ok, invoice} = CampaignFlow.Client.Invoices.create(client, %{
campaign_id: 123,
amount: 1000.00
})
# Update an invoice
{:ok, invoice} = CampaignFlow.Client.Invoices.update(client, 123, %{
amount: 1200.00
})Campaign Budgets
# List campaign budgets
{:ok, budgets} = CampaignFlow.Client.CampaignBudgets.list(client)
# Get a specific budget
{:ok, budget} = CampaignFlow.Client.CampaignBudgets.get(client, 123)
# Create a campaign budget
{:ok, budget} = CampaignFlow.Client.CampaignBudgets.create(client, %{
campaign_id: 123,
amount: 10000.00
})
# Update a campaign budget
{:ok, budget} = CampaignFlow.Client.CampaignBudgets.update(client, 123, %{
amount: 12000.00
})
# List finance options
{:ok, options} = CampaignFlow.Client.CampaignBudgets.list_finance_options(client, 123)
# Get a finance quote
{:ok, quote} = CampaignFlow.Client.CampaignBudgets.get_finance_quote(client, 123, "OPTION_CODE")Finance Applications
# List finance applications
{:ok, applications} = CampaignFlow.Client.FinanceApplications.list(client)
# Get a specific application
{:ok, application} = CampaignFlow.Client.FinanceApplications.get(client, 123)
# Set application status
{:ok, response} = CampaignFlow.Client.FinanceApplications.set_status(client, 123, %{
status: "approved"
})
# Submit an application
{:ok, response} = CampaignFlow.Client.FinanceApplications.submit(client, 123)Users, Tenants, Properties
# Users
{:ok, users} = CampaignFlow.Client.Users.list(client)
{:ok, user} = CampaignFlow.Client.Users.get(client, 123)
# Tenants
{:ok, tenants} = CampaignFlow.Client.Tenants.list(client)
{:ok, tenant} = CampaignFlow.Client.Tenants.get(client, 123)
# Properties
{:ok, properties} = CampaignFlow.Client.Properties.list(client)
{:ok, property} = CampaignFlow.Client.Properties.get(client, 123)Error Handling
The client returns tuples with {:ok, result} or {:error, reason}:
case CampaignFlow.Client.Campaigns.get(client, 123) do
{:ok, campaign} ->
# Process campaign
IO.inspect(campaign)
{:error, :not_found} ->
# Handle not found
IO.puts("Campaign not found")
{:error, :unauthorized} ->
# Handle authentication error
IO.puts("Authentication failed")
{:error, {:validation_error, details}} ->
# Handle validation errors
IO.inspect(details)
{:error, reason} ->
# Handle other errors
IO.inspect(reason)
endPagination
Most list endpoints support pagination:
# Get page 2 with 50 items per page
{:ok, campaigns} = CampaignFlow.Client.Campaigns.list(client,
page: 2,
per_page: 50
)Authentication
The client uses a shared token manager architecture for OAuth2 authentication:
- Application Startup: When your application starts, a
TokenManagerGenServer process is started for each configured environment (:prod,:test, etc.) - Automatic Token Acquisition: When you make your first API request, the TokenManager automatically obtains an access token using the OAuth2 client credentials flow
- Token Caching: The token is cached in the TokenManager process and shared across all processes in your application
- Automatic Refresh: When a token expires, it's automatically refreshed. Only one refresh occurs even with concurrent requests
- Race-Condition Free: GenServer's message queue ensures only one token refresh happens at a time, preventing duplicate refresh requests
How It Works
# Multiple processes can safely use the same client
client = CampaignFlow.Client.new(environment: :prod)
# These calls all share the same token from TokenManager
Task.async(fn -> CampaignFlow.Client.Campaigns.list(client) end)
Task.async(fn -> CampaignFlow.Client.Agencies.list(client) end)
Task.async(fn -> CampaignFlow.Client.Users.list(client) end)
# When the token expires, only ONE refresh request is made
# All concurrent calls automatically receive the new tokenBenefits
- Shared State: One token per environment, not one per client instance
- Thread-Safe: Race-condition-free token refresh coordination
- Memory Efficient: Reduced memory usage compared to per-client tokens
- Secure: Credentials never stored in client structs, only in TokenManager processes
- Simple: No need to manually manage or refresh tokens
Available Resources
The following resource modules are available:
CampaignFlow.Client.Campaigns- Campaign managementCampaignFlow.Client.Agencies- Agency operationsCampaignFlow.Client.Invoices- Invoice managementCampaignFlow.Client.CampaignBudgets- Campaign budget operationsCampaignFlow.Client.FinanceApplications- Finance application managementCampaignFlow.Client.Users- User operationsCampaignFlow.Client.Tenants- Tenant managementCampaignFlow.Client.Properties- Property operationsCampaignFlow.Client.Referrals- Referral managementCampaignFlow.Client.Signatories- Signatory operations
Mock Server (Embedded Mode)
The library ships with an optional embedded mock Campaign Flow server that you can run inside your own application. It's intended for:
- Local development without real API credentials
- Integration and end-to-end tests that exercise the full client → HTTP → server flow without hitting production
- CI environments where you want deterministic, sandboxed referral data
The mock server is implemented as a Plug router backed by your application's existing Ecto.Repo (PostgreSQL). It is opt-in and only loads when the optional dependencies are present.
Optional Dependencies
To use the mock server, add the following optional dependencies to your application's mix.exs:
def deps do
[
{:campaign_flow, "~> 2.1.0"},
# Required only if you use the embedded mock server:
{:bandit, "~> 1.0"},
{:plug, "~> 1.14"},
{:ecto_sql, "~> 3.10"},
{:postgrex, "~> 0.17"}
]
end
If you mount the mock router inside an existing Phoenix app, you likely already have plug and ecto_sql/postgrex and only need to ensure they are available.
Configuration
Enable the mock server in the environment(s) where you want it to run (typically config/dev.exs and config/test.exs):
# config/test.exs
config :campaign_flow, :mock_server,
enabled: true,
repo: MyApp.Repo
When enabled: true, the application supervisor automatically starts CampaignFlow.MockServer for you. The :repo option tells the mock server which Ecto.Repo to use for storage — it must be a Postgres repo owned by your application.
Then point a Campaign Flow client environment at the mock URL:
config :campaign_flow,
environments: [
mock: [
base_url: "http://localhost:4000/campaign-flow-mock/api/v2",
client_id: "test_key",
client_secret: "test_secret"
]
]
# In your code
client = CampaignFlow.Client.new(environment: :mock)
The mock server validates that an Authorization: Bearer ... header is present, but it does not verify the token value, so any non-empty client_id/client_secret will work.
Migrations
The mock server stores data in normalized PostgreSQL tables (properties, campaigns, campaign_budgets, agents, vendor_parties, referrals). Generate a migration in your application and call into CampaignFlow.MockServer.Migrations:
mix ecto.gen.migration add_campaign_flow_mock_serverdefmodule MyApp.Repo.Migrations.AddCampaignFlowMockServer do
use Ecto.Migration
def up, do: CampaignFlow.MockServer.Migrations.up(version: 1)
def down, do: CampaignFlow.MockServer.Migrations.down(version: 1)
end
You can pass a :prefix option to install the tables in a non-public schema if you need to isolate them.
Mounting in a Phoenix Router
Forward a path in your Phoenix router to CampaignFlow.MockServer.Router:
# lib/my_app_web/router.ex
forward "/campaign-flow-mock", CampaignFlow.MockServer.Router
This makes the mock available at http://localhost:4000/campaign-flow-mock/api/v2/... alongside the rest of your Phoenix app — no extra port, no separate process to manage.
Implemented Endpoints
The mock server currently implements a focused subset of the API for end-to-end referral testing:
| Method | Path | Description |
|---|---|---|
| POST | /api/v2/referrals |
Create a referral (atomic, normalized via Ecto.Multi) |
| GET | /api/v2/referrals/:id | Fetch a previously-created referral |
| GET | /health | Health check |
Additional endpoints can be added over time — contributions welcome.
Resetting State
Between tests you can clear all mock-server data with:
CampaignFlow.MockServer.clear_all()
If your test suite uses Ecto.Adapters.SQL.Sandbox, transactional rollbacks will normally handle cleanup for you and you won't need to call this directly.
Development
# Get dependencies
mix deps.get
# Run tests
mix test
# Generate documentation
mix docs
# Format code
mix formatLicense
MIT
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.