Skip to content

Commit a3eb6aa

Browse files
email-exporter: Use the upsert-by-email endpoint (#8297)
Prevent duplicate contacts in Pardot by using the upsert-by-email endpoint. Some background: In #7998 (comment) (later superseded by #8016), we discussed using this endpoint. It turns out I was wrong about Pardot storing Prospects by email address; you can have multiple Prospects with the same email. While subscribed newsletters won’t be sent to both contacts, the onboarding process doesn’t deduplicate in this way and will send to an email address each time it’s added, resulting in duplicate onboarding messages.
1 parent 05e6315 commit a3eb6aa

File tree

2 files changed

+39
-18
lines changed

2 files changed

+39
-18
lines changed

email/pardot.go

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ const (
1818
// tokenPath is the path to the Salesforce OAuth2 token endpoint.
1919
tokenPath = "/services/oauth2/token"
2020

21-
// contactsPath is the path to the Pardot v5 Prospects endpoint. This
22-
// endpoint will create a new Prospect if one does not already exist with
23-
// the same email address.
24-
contactsPath = "/api/v5/objects/prospects"
21+
// contactsPath is the path to the Pardot v5 Prospect upsert-by-email
22+
// endpoint. This endpoint will create a new Prospect if one does not
23+
// already exist with the same email address.
24+
//
25+
// https://developer.salesforce.com/docs/marketing/pardot/guide/prospect-v5.html#prospect-upsert-by-email
26+
contactsPath = "/api/v5/objects/prospects/do/upsertLatestByEmail"
2527

2628
// maxAttempts is the maximum number of attempts to retry a request.
2729
maxAttempts = 3
@@ -60,7 +62,7 @@ type PardotClientImpl struct {
6062
businessUnit string
6163
clientId string
6264
clientSecret string
63-
contactsURL string
65+
endpointURL string
6466
tokenURL string
6567
token *oAuthToken
6668
clk clock.Clock
@@ -70,7 +72,7 @@ var _ PardotClient = &PardotClientImpl{}
7072

7173
// NewPardotClientImpl creates a new PardotClientImpl.
7274
func NewPardotClientImpl(clk clock.Clock, businessUnit, clientId, clientSecret, oauthbaseURL, pardotBaseURL string) (*PardotClientImpl, error) {
73-
contactsURL, err := url.JoinPath(pardotBaseURL, contactsPath)
75+
endpointURL, err := url.JoinPath(pardotBaseURL, contactsPath)
7476
if err != nil {
7577
return nil, fmt.Errorf("failed to join contacts path: %w", err)
7678
}
@@ -83,7 +85,7 @@ func NewPardotClientImpl(clk clock.Clock, businessUnit, clientId, clientSecret,
8385
businessUnit: businessUnit,
8486
clientId: clientId,
8587
clientSecret: clientSecret,
86-
contactsURL: contactsURL,
88+
endpointURL: endpointURL,
8789
tokenURL: tokenURL,
8890
token: &oAuthToken{},
8991
clk: clk,
@@ -140,6 +142,19 @@ func redactEmail(body []byte, email string) string {
140142
return string(bytes.ReplaceAll(body, []byte(email), []byte("[REDACTED]")))
141143
}
142144

145+
type prospect struct {
146+
// Email is the email address of the prospect.
147+
Email string `json:"email"`
148+
}
149+
150+
type upsertPayload struct {
151+
// MatchEmail is the email address to match against existing prospects to
152+
// avoid adding duplicates.
153+
MatchEmail string `json:"matchEmail"`
154+
// Prospect is the prospect data to be upserted.
155+
Prospect prospect `json:"prospect"`
156+
}
157+
143158
// SendContact submits an email to the Pardot Contacts endpoint, retrying up
144159
// to 3 times with exponential backoff.
145160
func (pc *PardotClientImpl) SendContact(email string) error {
@@ -156,7 +171,10 @@ func (pc *PardotClientImpl) SendContact(email string) error {
156171
return fmt.Errorf("failed to update token: %w", err)
157172
}
158173

159-
payload, err := json.Marshal(map[string]string{"email": email})
174+
payload, err := json.Marshal(upsertPayload{
175+
MatchEmail: email,
176+
Prospect: prospect{Email: email},
177+
})
160178
if err != nil {
161179
return fmt.Errorf("failed to marshal payload: %w", err)
162180
}
@@ -165,7 +183,7 @@ func (pc *PardotClientImpl) SendContact(email string) error {
165183
for attempt := range maxAttempts {
166184
time.Sleep(core.RetryBackoff(attempt, retryBackoffMin, retryBackoffMax, retryBackoffBase))
167185

168-
req, err := http.NewRequest("POST", pc.contactsURL, bytes.NewReader(payload))
186+
req, err := http.NewRequest("POST", pc.endpointURL, bytes.NewReader(payload))
169187
if err != nil {
170188
finalErr = fmt.Errorf("failed to create new contact request: %w", err)
171189
continue

test/pardot-test-srv/main.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ func (ts *testServer) checkToken(w http.ResponseWriter, r *http.Request) {
8585
}
8686
}
8787

88-
func (ts *testServer) createContactsHandler(w http.ResponseWriter, r *http.Request) {
88+
func (ts *testServer) upsertContactsHandler(w http.ResponseWriter, r *http.Request) {
8989
ts.checkToken(w, r)
9090

9191
businessUnitId := r.Header.Get("Pardot-Business-Unit-Id")
@@ -100,19 +100,22 @@ func (ts *testServer) createContactsHandler(w http.ResponseWriter, r *http.Reque
100100
return
101101
}
102102

103-
type contactData struct {
104-
Email string `json:"email"`
103+
type upsertPayload struct {
104+
MatchEmail string `json:"matchEmail"`
105+
Prospect struct {
106+
Email string `json:"email"`
107+
} `json:"prospect"`
105108
}
106109

107-
var contact contactData
108-
err = json.Unmarshal(body, &contact)
110+
var payload upsertPayload
111+
err = json.Unmarshal(body, &payload)
109112
if err != nil {
110113
http.Error(w, "Failed to parse request body", http.StatusBadRequest)
111114
return
112115
}
113116

114-
if contact.Email == "" {
115-
http.Error(w, "Missing 'email' field in request body", http.StatusBadRequest)
117+
if payload.MatchEmail == "" || payload.Prospect.Email == "" {
118+
http.Error(w, "Missing 'matchEmail' or 'prospect.email' in request body", http.StatusBadRequest)
116119
return
117120
}
118121

@@ -122,7 +125,7 @@ func (ts *testServer) createContactsHandler(w http.ResponseWriter, r *http.Reque
122125
// with a small number of contacts, so it's fine.
123126
ts.contacts.created = ts.contacts.created[1:]
124127
}
125-
ts.contacts.created = append(ts.contacts.created, contact.Email)
128+
ts.contacts.created = append(ts.contacts.created, payload.Prospect.Email)
126129
ts.contacts.Unlock()
127130

128131
w.Header().Set("Content-Type", "application/json")
@@ -198,7 +201,7 @@ func main() {
198201

199202
// Pardot API Server
200203
pardotMux := http.NewServeMux()
201-
pardotMux.HandleFunc("/api/v5/objects/prospects", ts.createContactsHandler)
204+
pardotMux.HandleFunc("/api/v5/objects/prospects/do/upsertLatestByEmail", ts.upsertContactsHandler)
202205
pardotMux.HandleFunc("/contacts", ts.queryContactsHandler)
203206

204207
pardotServer := &http.Server{

0 commit comments

Comments
 (0)