Targeted Ads in SendGrid
Give every subscriber their own native ad in the newsletters you send with SendGrid. Each ad is a headline, description, button, and image, styled like the rest of your newsletter. AdButler picks the ad separately for each subscriber, using what you already know about them in SendGrid (their interests, industry, role, and so on), and counts every open and click in your normal AdButler reports.
This guide is in two halves. The first half is step by step, for everyone: what to do in AdButler, what to do in SendGrid, what to add to your newsletter, and when the sync runs. The second half, For developers, has the technical details for whoever sets up and schedules the sync.
If every subscriber can see the same ad, use Native ads in SendGrid instead. It's simpler.
How it works, in 30 seconds
SendGrid can fill in details for each subscriber when it sends (like {{first_name}}), but it can't ask AdButler for an ad. So a small program, the sync, does that ahead of time:
- Shortly before each newsletter goes out, the sync runs.
- For each subscriber, it asks AdButler: "which ad should this person see?" It sends that person's details, for example industry = fintech, interests = venture. AdButler picks the ad using your normal campaign targeting.
- The sync saves each subscriber's ad onto their SendGrid contact, in seven fields: headline, description, button text, image, links, and tracking pixel.
- SendGrid sends the newsletter. Your newsletter contains placeholders such as
{{scc_title}}, and SendGrid swaps in each subscriber's own ad. - AdButler counts each open and each click, like any other ad.
| Step | Where | Who | How often |
|---|---|---|---|
| Set up the template, ads, zone, and targeting | AdButler | You | Once, then per campaign |
| Create an API key and find your list ID | SendGrid | You | Once |
| Add the ad block to the newsletter | Your newsletter template | You or your developer | Once |
| Set up the sync | A server | Your IT team or developer | Once |
| The sync runs | Automatically, on a schedule | Nobody | Before every send |
Before you start
You'll need:
- An AdButler account with native ads.
- SendGrid Marketing Campaigns, with your subscribers stored as contacts in SendGrid.
- The details you want to target by, stored as contact fields in SendGrid, such as industry, role, or interests.
- Someone who can set up a scheduled job on a server, such as your IT team or a developer. This is a one-time setup.
Step 1: What you do in AdButler
1.1 Create a native template
A native template tells AdButler which pieces each ad has. This one has four.
- Go to Ad Items > Native Templates and click Add Template.
- Choose Custom Rendering / API.
- Add these four variables exactly as shown:
| Name | Placeholder | Type |
|---|---|---|
| Title | [%Title%] |
Text |
| Body | [%Body%] |
Text |
| CTAText | [%CTAText%] |
Text |
| Image | [%Image%] |
Image URL |
- In the template HTML box, paste the following, then click Save:
<div class="sponsored"><a href="[TRACKING_LINK]"><img src="[%Image%]" alt="[%Title%]" /></a><h3>[%Title%]</h3><p>[%Body%]</p><a href="[TRACKING_LINK]">[%CTAText%]</a></div>
1.2 Create your native ads
- Go to Advertisers, open the advertiser and the campaign, and click Add Ad Item.
- Choose Native (Custom Rendering / API) and pick the template from step 1.1.
- Fill in the Title (headline), Body (description), CTAText (button text, such as Register now), choose or upload the Image, and enter the Destination URL.
- Click Save Ad Item.
1.3 Create a dynamic zone
- Go to Publishers, open your publisher, and click Add Zone.
- Choose Standard Zone, name it something like Newsletter Sponsor, and set its size to Dynamic.
- Save it and write down the zone ID.
1.4 Target each campaign
This is how AdButler decides who sees what. Assign each campaign to the zone, and add targeting to the placement:
- Keyword targeting: for example, the Fintech Summit campaign targets the keyword
fintech. Anyone whose interests field includes fintech can get it. - Data key targeting: for example, target
industry = Bankingorrole = Analyst.
Leave one campaign untargeted as a fallback, so subscribers who don't match anything still get an ad. Weights, dates, and limits work as usual.
1.5 Turn on AccuPixel
Go to Settings > Ad Serving and turn on AccuPixel. This makes sure the sync's own requests aren't counted as impressions. Only real opens are.
1.6 Create an AdButler API key
Go to Settings > API Settings and create an API key. Your IT team needs it, along with your account ID and the zone ID from step 1.3.
Step 2: What you do in SendGrid
2.1 Create a SendGrid API key
- Go to Settings > API Keys and click Create API Key.
- Name it AdButler sync, choose Restricted Access, and set Marketing to Full Access.
- Click Create & View and copy the key. It's only shown once. Give it to your IT team securely.
2.2 Find your newsletter's audience
- Go to Marketing > Contacts.
- Open the list (or segment) your newsletter is sent to.
- Copy its ID: the long code at the end of the page address in your browser.
2.3 Note your targeting fields
Go to Marketing > Custom Fields and note the names of the fields that hold your subscribers' details, for example industry, role, and interests. Decide which ones match your AdButler targeting:
| SendGrid field | AdButler targeting |
|---|---|
interests (e.g. fintech, venture) |
Keywords |
industry (e.g. Banking) |
Data key industry |
role (e.g. Analyst) |
Data key role |
You don't need to create the seven ad fields; the sync creates them the first time it runs. They all start with scc_.
Step 3: Add the ad to your newsletter
Wherever your newsletter's HTML lives (in your CMS, such as Brightspot, or in SendGrid), add the ad block once where the ad should appear. Copy it from sendgrid/contact-fields-sponsor-block.html in the kit. In short, it's:
{{#if scc_title}}
<a href="{{{scc_clickurl}}}"><img src="{{{scc_banner}}}" alt="{{scc_title}}" width="600"></a>
<h3>{{scc_title}}</h3>
<p>{{scc_description}}</p>
<a href="{{{scc_ctalink}}}">{{scc_ctatext}}</a>
<img src="{{{scc_impressionurl}}}" width="1" height="1" alt="">
{{/if}}
| Placeholder | What it shows |
|---|---|
{{scc_title}} |
The headline |
{{scc_description}} |
The description |
{{scc_ctatext}} |
The button text |
{{{scc_banner}}} |
The image |
{{{scc_clickurl}}}, {{{scc_ctalink}}} |
The click link. Both are the same AdButler link, so every click is counted. |
{{{scc_impressionurl}}} |
An invisible pixel that counts each open |
- Change the look freely: the fonts, colours, button style, and image size are yours. Keep the placeholders as they are.
- Leave the
{{#if scc_title}}wrapper in place: it hides the whole block for anyone who has no ad.
{{{ }}} around the links and the image. Triple braces stop SendGrid from altering the web addresses.Step 4: Set up the sync (one time, by your IT team)
Send your IT team or developer this article and the kit, adbutler-sendgrid-developer-kit.zip, along with:
| What | From |
|---|---|
| AdButler account ID, zone ID, and API key | Step 1 |
| SendGrid API key and list or segment ID | Step 2 |
| Which fields are keywords and which are data keys | Step 2.3 |
| When each newsletter is sent | You |
They'll install it on a server, do a test run that shows which ad each subscriber would get without changing anything, and schedule it to run before each send. All the details are in For developers.
Step 5: Every time a newsletter goes out
Once it's set up, you don't need to do anything. Just keep these in mind:
- The sync runs on a schedule, for example 60 minutes before each newsletter. Give it enough time to finish; for very large lists, your IT team will tell you how long a run takes.
- Sending at an unusual time? Ask IT to run the sync first. Otherwise subscribers see the ads from the previous send.
- Changing the ads? Update your campaigns in AdButler as usual. The next sync picks up the changes.
What happens after you send
- Each subscriber sees their own ad, chosen for them at the time of the sync.
- Someone opens the email: AdButler counts one impression, every time it's opened.
- Someone clicks the ad or the button: AdButler counts one click, then sends them to the advertiser's site.
- Subscribers with no matching ad see no ad block at all.
If something goes wrong
| What you notice | What to check |
|---|---|
| Nobody sees an ad | Did the sync run before this send? Is the ad block (step 3) in the newsletter? |
| Subscribers see last time's ads | The sync didn't run before this send. Run it before every send. |
| Nobody in a group sees the right ad | Check that the campaign's targeting (step 1.4) matches the field values in SendGrid exactly, including spelling. |
| Many subscribers have no ad | Add an untargeted fallback campaign (step 1.4). |
Text shows {{scc_title}} literally |
The placeholders were changed or removed by the newsletter tool. Ask your developer to check that they reach SendGrid unchanged. |
Good to know
- Geography doesn't work for this kind of ad. The ads are chosen on a server, not in the subscriber's location, so target by your subscribers' data rather than by location.
- Cookie-based frequency caps don't apply for the same reason.
- Each subscriber uses one ad request per send from your AdButler plan, plus one per open.
- Apple Mail pre-loads images, so some opens are counted automatically, as with every email platform.
- SendGrid may wrap links with its own click tracking. Clicks still go through AdButler.
For developers
Everything below is for whoever installs and schedules the sync.
What the sync does
node/sync-contacts.mjs (Node 18+) and python/sync_contacts.py (Python 3.8+) are identical in behaviour, and neither has dependencies:
- Export the audience.
POST /v3/marketing/contacts/exportswithSYNC_LIST_IDS/SYNC_SEGMENT_IDS(CSV), pollGET /v3/marketing/contacts/exports/{id}until ready, and download the files. The files may be gzipped. Alternatively,--contacts-filereads a CSV, JSON array, or JSON-lines file. - Decide each subscriber's ad, one request per contact:
GET https://servedbyadbutler.com/adserve/;ID={ACCOUNT};size=0x0;setID={ZONE};type=json;output_json_native_template_mode=2;kw=fintech,venture;_abdk_json={"industry":"Banking","role":"Analyst"}- Keywords come from
SYNC_KEYWORD_FIELDS; comma-separated values are split. - Data keys come from
SYNC_DATAKEY_FIELDS. Writeindustryto use the field name as the key, orkey:fieldto rename it. - Field lookup is case-insensitive.
- Keywords come from
- Get tracking links once per distinct ad, using
GET /v2/manual-tracking-links?placement=&ad_item=&type=impression,click_redirect. They're cached for the run; failures aren't cached. - Write the custom fields. The seven
scc_*Text fields are created if missing (/v3/marketing/field_definitions). Contacts are upserted withPUT /v3/marketing/contactsin batches under 5 MB / 30,000 contacts, and the script pollsGET /v3/marketing/contacts/imports/{job_id}until each job iscompleted. It exits non-zero on any failure.
| Field | Value |
|---|---|
scc_title, scc_description, scc_ctatext, scc_banner |
Native variables Title, Body, CTAText, Image (names configurable) |
scc_ctalink, scc_clickurl |
The manual click_redirect link |
scc_impressionurl |
The manual impression pixel plus ;cb=<per-subscriber hash>, which keeps the signature valid and stops proxy caching |
| (no eligible ad, error, non-native winner) | All seven empty, so stale ads are cleared |
Install
- Unzip adbutler-sendgrid-developer-kit.zip on the server. Node 18+ or Python 3.8+ is required, with nothing else to install.
- Copy
.env.exampleto.envand fill it in:
ADBUTLER_ACCOUNT_ID=123456
ADBUTLER_ZONE_ID=789012
ADBUTLER_API_KEY=...
SENDGRID_API_KEY=SG....
SYNC_LIST_IDS=1a2b3c4d-.... # or SYNC_SEGMENT_IDS
SYNC_KEYWORD_FIELDS=interests
SYNC_DATAKEY_FIELDS=industry,role
# ADBUTLER_BODY_VAR=Body ADBUTLER_CTATEXT_VAR=CTAText # if your template uses other names
- Test without writing anything. Example output (yours will differ):
node node/sync-contacts.mjs --dry-run --limit 20
exporting audience from SendGrid (job 9b2f...)...
20 contacts
decided in 0.6s: 20 ad requests, 18 with an ad, 2 without (0 errors, 0 non-native)
ad item 523700001: 11
ad item 523700002: 7
dry run, nothing written. First 3 contacts:
[ { "email": "pat@example.com", "fields": { "title": "Fintech Summit", ... } } ]
- Do a full run (example output below), then check a few contacts in SendGrid (Marketing > Contacts, open a contact, and look for the
scc_fields):
node node/sync-contacts.mjs
250000 contacts
decided in 212.4s: 250000 ad requests, 241002 with an ad, 8998 without (0 errors, 0 non-native)
batch 1/84: 2980 contacts queued (job 3f1c...)
...
waiting for SendGrid to apply the updates...
done in 1093.0s: 250000 contacts updated. The newsletter can be sent now.
The run time grows with the list size and the network. Time a full run with your real list, and use that figure for the schedule in the next step.
Schedule it
Run the sync before each send, with room to spare beyond the time a full run takes. For example, for a newsletter at 6:00 every weekday:
# Linux cron: 4:30 every weekday
30 4 * * 1-5 cd /opt/adbutler-sendgrid && /usr/bin/node node/sync-contacts.mjs >> sync.log 2>&1
:: Windows Task Scheduler, action
Program: C:\Program Files\nodejs\node.exe
Arguments: node\sync-contacts.mjs
Start in: C:\adbutler-sendgrid
- Other schedulers: a cloud scheduler (AWS EventBridge, Azure, Google Cloud Scheduler) running a container or function works too. The process is a single run-to-completion command.
- Alert on failure: it exits with code 1 on any failure. Hook that into your alerting, so a failed sync never goes unnoticed before a send.
- Several newsletters: use a separate
.env(or environment variables) per newsletter, if they have different audiences or zones.
Options
| Flag or setting | Purpose |
|---|---|
--dry-run |
Decide and print, but write nothing |
--limit N |
Only the first N contacts (for testing) |
--contacts-file FILE |
Read contacts from a CSV/JSON file instead of exporting from SendGrid |
--clear |
Set all seven fields to empty on every contact, without asking AdButler |
SYNC_CONCURRENCY |
Ad requests in flight (default 25) |
SYNC_GROUP_IDENTICAL=1 |
One ad request per distinct targeting combination instead of per subscriber. Far fewer requests, but everyone in a group gets the same ad. |
SYNC_FIELD_PREFIX |
Field-name prefix (default scc_) |
ADBUTLER_TITLE_VAR, ADBUTLER_BODY_VAR, ADBUTLER_CTATEXT_VAR, ADBUTLER_IMAGE_VAR |
Native template variable names |
SYNC_SEND_ID |
Seed for the per-subscriber pixel token (default: today's date) |
Scale and limits
- Ad requests: one per subscriber per sync. They count toward the account's ad request volume. With AccuPixel on, they record no impressions; only pixel loads (opens) do.
- Throughput: in testing, 200,000 subscribers were decided in about 9 seconds against a local mock, using about 1 GB of memory. Against the real ad servers, the time depends on network latency and
SYNC_CONCURRENCY. - Geo targeting: won't work, because requests come from the sync server's IP address.
- Cookie-based frequency caps: don't apply.
- User agent: requests carry
AdButler-SendGrid-Sync/1.0, which isn't treated as a bot by AdButler. - Not sending a stale ad: the fields persist on contacts, so every send needs a fresh sync.
--clearafter a send is optional.
Testing checklist
- Run
--dry-run --limit 20and confirm the ads match each subscriber's targeting. - Do a full run against a list containing only your own test contacts, with different field values.
- Check the
scc_fields on those contacts in SendGrid. - Send the newsletter to that list. Each test contact should receive its own ad.
- Open one email twice and click once. AdButler should show 2 impressions and 1 click for that ad.
- Pause all campaigns, sync again, and send. No ad block should appear.