Using Salesforce.com Email

This guide shows how to precompute ads from Koddi for Salesforce email sends using the Winning Ads API, then inject the returned creative and click URL into a Salesforce email template.

How it works

  1. A Salesforce user prepares a campaign audience.
  2. A custom button starts ad preparation.
  3. Salesforce processes Campaign Members in controlled batches.
  4. For each eligible record, Salesforce calls Koddi Winning Ads server-side.
  5. Salesforce stores the returned image URL, destination URL and and click URL on the Campaign Member.
  6. The email template renders those fields directly.

Why use a button instead of an automatic trigger

A button-driven workflow is preferred for this use case.

It gives operators control over when ad generation happens, it prevents accidental burst traffic when large lists are loaded, and it allows the process to run with batching and pacing rules that protect both Salesforce and Koddi.

Use a button labeled Prepare Koddi Ads on the Campaign page.

Recommended Salesforce data model

Store ad payload data on Campaign Member so the same contact can receive different ads across different campaigns.

Custom fields on Campaign Member:

  • Koddi_Ad_Creative_URL__c, Text(255)
  • Koddi_Ad_Click_URL__c, Long Text Area
  • Koddi_Status__c, Text(255)
  • Koddi_Error__c, Long Text Area
  • Koddi_Retry_Count__c, Number
  • Koddi_Next_Attempt_At__c, DateTime
  • Koddi_Last_Updated__c, DateTime

Campaign-level fields:

  • Koddi_Experience_Name__c, Picklist of Experiences set up in Koddi (your Koddi team can help with set up)
  • Koddi_Prepare_Status__c, Text(255)
  • Koddi_Prepare_Started_At__c, DateTime
  • Koddi_Prepare_Completed_At__c, DateTime

API endpoints used

Primary auction call:

POST https://{clientname}.koddi.io/auction-engine/winning_ads

Measurement endpoints for later phases:

GET  https://{clientname}.koddi.io/event-collection/beacon?action=click

Authentication

Set up a Salesforce Named Credential for Koddi.

Suggested configuration:

  • Label: Koddi API
  • Name: Koddi_API
  • URL: https://{clientname}.koddi.io
  • Auth: Your Koddi team will provide an API user email/password.

Use the Named Credential from Apex so credentials are not hardcoded.

Required Koddi request concepts

Every Winning Ads request should include:

  • A stable user identifier
  • An email experience configured in Koddi (your Koddi team can help)
  • Optional segments or targeting context

Example Winning Ads request body

{
  "client_name": "Sample Store",
  "domain": "www.store.com",
  "site_id": "Sample Store",
  "page_name": "Email",
  "experience_name": "Email 600px",
  "max_requested": 1,
  "slots_available": 1,
  "targeting": {
     "new_cust": 0,
     "audience_id": 19478230234
  }
}

In Salesforce, these values should be mapped as follows:

  • experience_name comes from Campaign.Koddi_Experience_Name__c
  • client_name, domain, site_id, and page_name should come from configuration values agreed with the Koddi team
  • max_requested should be 1 for single-slot email use cases
  • slots_available should be 1 for a single ad slot in the email
  • targeting should be populated from audience or CRM attributes when available

Example response handling

Your code should expect one or more ads. For email, use the first eligible ad returned for the experience.

{
  "errors": [],
  "sponsored_listings": [
    {
      "click_tracking_url": "https://koddi.io/click/abc123",
      "bidder": "100000006",
      "advertiser_name": "Campbell's Soup",
      "impression_tracking_url": "https://koddi.io/impression/xyz456",
      "tracking_data": "encoded_string",
      "cpm": 29,
      "bid": 31,
      "tracking_guid": "GUID",
      "price_floor": 0.50,
      "price_floor_type": 1,
      "assets": {
        "creative_url": "https://cdn.koddi.com/creative.jpg",
        "creative_destination": "https://brand.example.com/landing-page",
        "creative_name": "Creative Name"
      }
    }
  ]
}

Build the workflow in Salesforce

Step 1: Create fields on Campaign and Campaign Member

  1. In Setup, go to Object Manager, Campaign Member, Fields and Relationships, then create the custom fields listed above.
  2. Add campaign-level status and experience fields (listed above) so the operator can select which Koddi experience should be used and monitor preparation progress.

Step 2: Set up custom metadata

  1. Go to Setup
  2. In the Quick Find box, type: Custom Metadata Types
  3. Click Custom Metadata Types
  4. Create a Custom Metadata Type named:Koddi_Email_Config__mdt
  5. Create these custom fields on it:
  • Client_Name__c, Text(255)
  • Domain__c, Text(255)
  • Site_Id__c, Text(255)
  • Page_Name__c, Text(255)
  • Is_Active__c, Checkbox
  1. Then create one record with values from your Koddi team.

Step 3: Create the named credential

Configure the Koddi base URL and auth so Apex callouts can use callout:Koddi_API.

Step 4: Implement Apex code

Deploy these 4 classes:

  • KoddiCampaignController
  • KoddiPrepareAdsBatch
  • KoddiRetryDispatcher
  • KoddiRetrySchedulerService

KoddiCampaignController

public with sharing class KoddiCampaignController {

    @AuraEnabled
    public static String prepareCampaignAds(Id campaignId) {
        if (campaignId == null) {
            throw new AuraHandledException('Campaign Id is required.');
        }

        Campaign campaignRecord = [
            SELECT Id, Name, Koddi_Experience_Name__c
            FROM Campaign
            WHERE Id = :campaignId
            LIMIT 1
        ];

        if (String.isBlank(campaignRecord.Koddi_Experience_Name__c)) {
            throw new AuraHandledException(
                'Koddi Experience Name is required before preparing ads.'
            );
        }

        Integer eligibleCount = [
            SELECT COUNT()
            FROM CampaignMember
            WHERE CampaignId = :campaignId
              AND ContactId != null
        ];

        if (eligibleCount == 0) {
            throw new AuraHandledException(
                'No eligible Campaign Members were found for this campaign.'
            );
        }

        update new Campaign(
            Id = campaignRecord.Id,
            Koddi_Prepare_Status__c = 'In Progress',
            Koddi_Prepare_Started_At__c = System.now(),
            Koddi_Prepare_Completed_At__c = null
        );

        Database.executeBatch(
            new KoddiPrepareAdsBatch(
                campaignRecord.Id,
                campaignRecord.Koddi_Experience_Name__c
            ),
            10
        );

        return 'Started';
    }
}

KoddiPrepareAdsBatch

global with sharing class KoddiPrepareAdsBatch
    implements Database.Batchable<SObject>, Database.AllowsCallouts, Database.Stateful {

    private static final Integer MAX_RETRIES = 3;

    global Id campaignId;
    global String experienceName;

    global KoddiPrepareAdsBatch(Id campaignId, String experienceName) {
        this.campaignId = campaignId;
        this.experienceName = experienceName;
    }

    global Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator([
            SELECT Id,
                   ContactId,
                   CampaignId,
                   Koddi_Status__c,
                   Koddi_Error__c,
                   Koddi_Retry_Count__c,
                   Koddi_Next_Attempt_At__c
            FROM CampaignMember
            WHERE CampaignId = :campaignId
              AND ContactId != null
              AND (
                    Koddi_Status__c = null
                 OR Koddi_Status__c = 'Pending'
                 OR (
                        Koddi_Status__c = 'Failed'
                    AND Koddi_Next_Attempt_At__c != null
                    AND Koddi_Next_Attempt_At__c <= :System.now()
                    AND (Koddi_Retry_Count__c = null OR Koddi_Retry_Count__c < :MAX_RETRIES)
                 )
              )
        ]);
    }

    global void execute(Database.BatchableContext bc, List<CampaignMember> scope) {
        Koddi_Email_Config__mdt configRecord = getActiveConfig();

        Http http = new Http();
        List<CampaignMember> updates = new List<CampaignMember>();

        for (CampaignMember campaignMemberRecord : scope) {
            HttpRequest request = new HttpRequest();
            request.setEndpoint('callout:Koddi_API/auction-engine/winning_ads');
            request.setMethod('POST');
            request.setHeader('Content-Type', 'application/json');
            request.setTimeout(120000);

            CampaignMember updateRow = new CampaignMember(Id = campaignMemberRecord.Id);

            Map<String, Object> payload = new Map<String, Object>{
                'client_name' => configRecord.Client_Name__c,
                'domain' => configRecord.Domain__c,
                'site_id' => configRecord.Site_Id__c,
                'page_name' => configRecord.Page_Name__c,
                'experience_name' => experienceName,
                'max_requested' => 1,
                'slots_available' => 1,
                'targeting' => buildTargeting(campaignMemberRecord)
            };

            request.setBody(JSON.serialize(payload));

            try {
                HttpResponse response = http.send(request);

                if (response.getStatusCode() == 200) {
                    handleSuccessResponse(updateRow, response);
                } else if (response.getStatusCode() == 429 || response.getStatusCode() >= 500) {
                    markRetryableFailure(
                        campaignMemberRecord,
                        updateRow,
                        'Retryable HTTP ' + response.getStatusCode()
                    );
                } else {
                    markTerminalFailure(
                        updateRow,
                        'HTTP ' + response.getStatusCode() + ': ' + safeTrim(response.getBody(), 32000)
                    );
                }
            } catch (Exception ex) {
                markRetryableFailure(campaignMemberRecord, updateRow, safeTrim(ex.getMessage(), 32000));
            }

            updates.add(updateRow);
        }

        if (!updates.isEmpty()) {
            update updates;
        }
    }

    global void finish(Database.BatchableContext bc) {
        Integer runnableNow = [
            SELECT COUNT()
            FROM CampaignMember
            WHERE CampaignId = :campaignId
              AND ContactId != null
              AND (
                    Koddi_Status__c = null
                 OR Koddi_Status__c = 'Pending'
                 OR (
                        Koddi_Status__c = 'Failed'
                    AND Koddi_Next_Attempt_At__c != null
                    AND Koddi_Next_Attempt_At__c <= :System.now()
                    AND (Koddi_Retry_Count__c = null OR Koddi_Retry_Count__c < :MAX_RETRIES)
                 )
              )
        ];

        Integer futureRetryCount = [
            SELECT COUNT()
            FROM CampaignMember
            WHERE CampaignId = :campaignId
              AND Koddi_Status__c = 'Failed'
              AND Koddi_Next_Attempt_At__c != null
              AND Koddi_Next_Attempt_At__c > :System.now()
              AND (Koddi_Retry_Count__c = null OR Koddi_Retry_Count__c < :MAX_RETRIES)
        ];

        Integer exhaustedFailures = [
            SELECT COUNT()
            FROM CampaignMember
            WHERE CampaignId = :campaignId
              AND Koddi_Status__c = 'Failed'
              AND Koddi_Retry_Count__c >= :MAX_RETRIES
        ];

        if (runnableNow == 0 && futureRetryCount > 0) {
            update new Campaign(
                Id = campaignId,
                Koddi_Prepare_Status__c = 'Waiting For Retry'
            );
        } else if (runnableNow == 0 && futureRetryCount == 0 && exhaustedFailures > 0) {
            update new Campaign(
                Id = campaignId,
                Koddi_Prepare_Status__c = 'Complete With Errors',
                Koddi_Prepare_Completed_At__c = System.now()
            );
        } else if (runnableNow == 0 && futureRetryCount == 0 && exhaustedFailures == 0) {
            update new Campaign(
                Id = campaignId,
                Koddi_Prepare_Status__c = 'Complete',
                Koddi_Prepare_Completed_At__c = System.now()
            );
        } else {
            update new Campaign(
                Id = campaignId,
                Koddi_Prepare_Status__c = 'In Progress'
            );
        }

        KoddiRetrySchedulerService.scheduleNextRetryIfNeeded();
    }

    private static Koddi_Email_Config__mdt getActiveConfig() {
        List<Koddi_Email_Config__mdt> configRows = [
            SELECT Client_Name__c,
                   Domain__c,
                   Site_Id__c,
                   Page_Name__c,
                   Is_Active__c
            FROM Koddi_Email_Config__mdt
            WHERE Is_Active__c = true
            ORDER BY DeveloperName ASC
            LIMIT 1
        ];

        if (configRows.isEmpty()) {
            throw new KoddiBatchConfigurationException(
                'No active Koddi_Email_Config__mdt record was found.'
            );
        }

        Koddi_Email_Config__mdt configRecord = configRows[0];

        if (String.isBlank(configRecord.Client_Name__c)
            || String.isBlank(configRecord.Domain__c)
            || String.isBlank(configRecord.Site_Id__c)
            || String.isBlank(configRecord.Page_Name__c)) {
            throw new KoddiBatchConfigurationException(
                'Active Koddi email config is missing required values.'
            );
        }

        return configRecord;
    }

    private static Map<String, Object> buildTargeting(CampaignMember campaignMemberRecord) {
        return new Map<String, Object>{
            'page_placement' => 'email_body',
            'crm_contact_id' => String.valueOf(campaignMemberRecord.ContactId)
        };
    }

    private static void handleSuccessResponse(
        CampaignMember updateRow,
        HttpResponse response
    ) {
        Map<String, Object> body =
            (Map<String, Object>) JSON.deserializeUntyped(response.getBody());

        List<Object> listings = (List<Object>) body.get('sponsored_listings');

        if (listings != null && !listings.isEmpty()) {
            Map<String, Object> firstListing =
                (Map<String, Object>) listings[0];

            Map<String, Object> assets =
                (Map<String, Object>) firstListing.get('assets');

            String creativeUrl;
            String destinationUrl;
            String clickTrackingUrl = (String) firstListing.get('click_tracking_url');

            if (assets != null) {
                creativeUrl = (String) assets.get('creative_url');
                destinationUrl = (String) assets.get('creative_destination');
            }

            updateRow.Koddi_Ad_Creative_URL__c = creativeUrl;
            updateRow.Koddi_Ad_Destination_URL__c = destinationUrl;
            updateRow.Koddi_Ad_Click_URL__c = buildTrackedClickUrl(clickTrackingUrl, destinationUrl);
            updateRow.Koddi_Status__c = 'Ready';
            updateRow.Koddi_Error__c = null;
            updateRow.Koddi_Retry_Count__c = 0;
            updateRow.Koddi_Next_Attempt_At__c = null;
            updateRow.Koddi_Last_Updated__c = System.now();
        } else {
            updateRow.Koddi_Status__c = 'No Fill';
            updateRow.Koddi_Error__c = null;
            updateRow.Koddi_Next_Attempt_At__c = null;
            updateRow.Koddi_Last_Updated__c = System.now();
        }
    }

    private static String buildTrackedClickUrl(String clickTrackingUrl, String destinationUrl) {
        if (String.isBlank(clickTrackingUrl)) {
            return null;
        }

        if (String.isBlank(destinationUrl)) {
            return clickTrackingUrl;
        }

        if (clickTrackingUrl.contains('destURL=')) {
            return clickTrackingUrl;
        }

        String encodedDest = EncodingUtil.urlEncode(destinationUrl, 'UTF-8');
        String separator = clickTrackingUrl.contains('?') ? '&' : '?';

        return clickTrackingUrl + separator + 'destURL=' + encodedDest;
    }

    private static void markRetryableFailure(
        CampaignMember originalRow,
        CampaignMember updateRow,
        String errorMessage
    ) {
        Integer retryCount = (originalRow.Koddi_Retry_Count__c == null)
            ? 0
            : Integer.valueOf(originalRow.Koddi_Retry_Count__c);

        retryCount++;

        updateRow.Koddi_Status__c = 'Failed';
        updateRow.Koddi_Error__c = safeTrim(errorMessage, 32000);
        updateRow.Koddi_Retry_Count__c = retryCount;
        updateRow.Koddi_Next_Attempt_At__c = nextAttemptForRetry(retryCount);
        updateRow.Koddi_Last_Updated__c = System.now();
    }

    private static void markTerminalFailure(CampaignMember updateRow, String errorMessage) {
        updateRow.Koddi_Status__c = 'Failed';
        updateRow.Koddi_Error__c = safeTrim(errorMessage, 32000);
        updateRow.Koddi_Next_Attempt_At__c = null;
        updateRow.Koddi_Last_Updated__c = System.now();
    }

    private static Datetime nextAttemptForRetry(Integer retryCount) {
        if (retryCount <= 1) {
            return System.now().addMinutes(5);
        }
        if (retryCount == 2) {
            return System.now().addMinutes(15);
        }
        return System.now().addHours(1);
    }

    private static String safeTrim(String valueToTrim, Integer maxLength) {
        if (valueToTrim == null) {
            return null;
        }
        if (valueToTrim.length() <= maxLength) {
            return valueToTrim;
        }
        return valueToTrim.substring(0, maxLength);
    }

    global class KoddiBatchConfigurationException extends Exception {}
}

KoddiRetryDispatcher

global with sharing class KoddiRetryDispatcher implements Schedulable {

    private static final Integer MAX_RETRIES = 3;

    global void execute(SchedulableContext sc) {
        List<AggregateResult> campaignsToRetry = [
            SELECT CampaignId campaignId
            FROM CampaignMember
            WHERE Koddi_Status__c = 'Failed'
              AND (Koddi_Retry_Count__c = null OR Koddi_Retry_Count__c < :MAX_RETRIES)
              AND Koddi_Next_Attempt_At__c != null
              AND Koddi_Next_Attempt_At__c <= :System.now()
              AND CampaignId != null
            GROUP BY CampaignId
        ];

        for (AggregateResult resultRow : campaignsToRetry) {
            Id campaignId = (Id) resultRow.get('campaignId');

            Campaign campaignRecord = [
                SELECT Id, Koddi_Experience_Name__c
                FROM Campaign
                WHERE Id = :campaignId
                LIMIT 1
            ];

            if (!String.isBlank(campaignRecord.Koddi_Experience_Name__c)) {
                update new Campaign(
                    Id = campaignRecord.Id,
                    Koddi_Prepare_Status__c = 'In Progress'
                );

                Database.executeBatch(
                    new KoddiPrepareAdsBatch(
                        campaignRecord.Id,
                        campaignRecord.Koddi_Experience_Name__c
                    ),
                    10
                );
            }
        }

        KoddiRetrySchedulerService.scheduleNextRetryIfNeeded();
    }
}

KoddiRetrySchedulerService

public with sharing class KoddiRetrySchedulerService {

    private static final String JOB_NAME = 'Koddi Retry Dispatcher';
    private static final Integer MAX_RETRIES = 3;

    public static void scheduleNextRetryIfNeeded() {
        Datetime nextRetryTime = getNextRetryTime();

        if (nextRetryTime == null) {
            abortExistingJobIfAny();
            return;
        }

        Datetime scheduledRunAt = normalizeToNextMinute(nextRetryTime);
        String cronExpression = toCron(scheduledRunAt);

        CronTrigger existingJob = getExistingJob();
        if (existingJob != null) {
            Datetime existingRunAt = existingJob.NextFireTime;

            if (existingRunAt != null && existingRunAt.getTime() == scheduledRunAt.getTime()) {
                return;
            }

            System.abortJob(existingJob.Id);
        }

        System.schedule(JOB_NAME, cronExpression, new KoddiRetryDispatcher());
    }

    private static Datetime getNextRetryTime() {
        List<CampaignMember> retryRows = [
            SELECT Koddi_Next_Attempt_At__c
            FROM CampaignMember
            WHERE Koddi_Status__c = 'Failed'
              AND (Koddi_Retry_Count__c = null OR Koddi_Retry_Count__c < :MAX_RETRIES)
              AND Koddi_Next_Attempt_At__c != null
            ORDER BY Koddi_Next_Attempt_At__c ASC
            LIMIT 1
        ];

        return retryRows.isEmpty() ? null : retryRows[0].Koddi_Next_Attempt_At__c;
    }

    private static CronTrigger getExistingJob() {
        List<CronTrigger> jobRows = [
            SELECT Id, NextFireTime, State, CronJobDetail.Name
            FROM CronTrigger
            WHERE CronJobDetail.Name = :JOB_NAME
              AND State != 'DELETED'
            LIMIT 1
        ];

        return jobRows.isEmpty() ? null : jobRows[0];
    }

    private static void abortExistingJobIfAny() {
        CronTrigger existingJob = getExistingJob();
        if (existingJob != null) {
            System.abortJob(existingJob.Id);
        }
    }

    private static Datetime normalizeToNextMinute(Datetime inputTime) {
        Datetime rounded = Datetime.newInstance(
            inputTime.year(),
            inputTime.month(),
            inputTime.day(),
            inputTime.hour(),
            inputTime.minute(),
            0
        );

        if (rounded <= System.now()) {
            rounded = rounded.addMinutes(1);
        }

        return rounded;
    }

    private static String toCron(Datetime inputTime) {
        return '0 ' +
            String.valueOf(inputTime.minute()) + ' ' +
            String.valueOf(inputTime.hour()) + ' ' +
            String.valueOf(inputTime.day()) + ' ' +
            String.valueOf(inputTime.month()) + ' ? ' +
            String.valueOf(inputTime.year());
    }
}

Step 5:Add a custom button on Campaign

Go to: Setup → Object Manager → Campaign → Buttons, Links, and Actions

  • Action Type: Lightning Component
  • Apex Label: Prepare Koddi Ads

If using Apex directly:

  • Use an Aura or LWC wrapper to call:
KoddiCampaignController.prepareCampaignAds(recordId)

Add it to the Campaign page layout.

📘

We do not suggest automatically calling Koddi when Campaign Members are inserted.

That pattern is risky because a bulk import can instantly create a burst of thousands of requests. It also makes retries, status handling, and operator visibility harder.

Use an explicit button or scheduled job instead.

The email template

Once fields are prepared, the email template will look like:

<a href="{!CampaignMember.Koddi_Ad_Click_URL__c}">
  <img src="{!CampaignMember.Koddi_Ad_Creative_URL__c}" width="600" alt="" />
</a>

Add a default fallback

Always define a fallback so a blank ad does not break the email.

Two common options:

  • Prepopulate default image and click URLs before Koddi preparation
  • Only send to Campaign Members with Koddi_Status__c = 'Ready'

The second option is safer for monetized inventory.

Operational flow for users

  1. Create or open a Campaign
  2. Set Koddi_Placement_Id__c(picklist)
  3. Add Campaign Members
  4. Click Prepare Koddi Ads
  5. Monitor preparation status
  6. Verify enough members are Ready
  7. Send the email

Logging and visibility

At minimum, report these counts on the Campaign record or a simple dashboard:

  • Pending
  • Ready
  • No Fill
  • Failed

This keeps the operator from sending before the audience is actually prepared.

Testing checklist

Before production launch:

  • Confirm experience name is valid in Koddi
  • Confirm Winning Ads returns creative for test users
  • Confirm Campaign Member fields populate correctly
  • Confirm fallback handling for no-fill cases
  • Confirm button can handle a realistic audience size
  • Confirm click URL resolves as expected