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

Installation

Add campaign_flow to your list of dependencies in mix.exs:

def deps do
  [
    {:campaign_flow, "~> 2.1.0"}
  ]
end

Configuration

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")
      ]
    ]
end

Environment 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)
end

Pagination

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:

  1. Application Startup: When your application starts, a TokenManager GenServer process is started for each configured environment (:prod, :test, etc.)
  2. Automatic Token Acquisition: When you make your first API request, the TokenManager automatically obtains an access token using the OAuth2 client credentials flow
  3. Token Caching: The token is cached in the TokenManager process and shared across all processes in your application
  4. Automatic Refresh: When a token expires, it's automatically refreshed. Only one refresh occurs even with concurrent requests
  5. 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 token

Benefits

Available Resources

The following resource modules are available:

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:

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_server
defmodule 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 format

License

MIT

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.