Clay
Clay finds and enriches the people. anymail emails them. The link between the two is Clay's HTTP API column: for each row it calls the REST API and the person lands in an anymail campaign. There is nothing to install and nothing to connect on the anymail side. The campaign travels in the URL.
What you need#
- An API key with the
writescope. In anymail open Settings, Integrations, API keys. It starts withsgn_. - A campaign in
draftoractivestatus. Its id is in the address bar of the campaign page. Settings, Integrations, Clay shows the ready URL for each campaign. - A Clay table with at least an email column.
Column 1: create the prospect and enrol it#
Add a column, choose HTTP API, and fill it in:
| Field | Value |
|---|---|
| Method | POST |
| Endpoint | https://app.anymail.watch/api/v1/prospects?campaign=<campaign_id> |
| Headers | Authorization: Bearer sgn_…, Content-Type: application/json |
| Body | the JSON below, with Clay columns inserted where the / menu puts them |
{
"email": "{{Email}}",
"first_name": "{{First Name}}",
"last_name": "{{Last Name}}",
"company_name": "{{Company}}",
"custom_fields": {
"title": "{{Job Title}}",
"icebreaker": "{{Icebreaker}}"
}
}
Only email is required. custom_fields is a flat map of strings. Every key becomes a snippet in your sequence, so {{icebreaker}} in an anymail step prints that row's Clay column. Numbers and booleans are turned into text. Nested objects are rejected with 400.
anymail answers:
| Status | Meaning |
|---|---|
201 |
The prospect was created and enrolled. The body has id, email and company_id. |
409 |
The address is already in your workspace ("code": "duplicate"). It was not enrolled. Column 2 handles it. |
400 |
The address is not valid, the body is not JSON, or the campaign id is unknown or the campaign is not draft/active. An unknown campaign rejects the row before anything is created. |
401 / 403 |
The key is wrong, deleted or read-only. |
429 |
Too fast. See Limits. |
Column 2: enrol people who already exist#
A 409 row needs one more call. Add a second HTTP API column:
| Field | Value |
|---|---|
| Method | POST |
| Endpoint | https://app.anymail.watch/api/v1/campaigns/<campaign_id>/prospects |
| Headers | the same as column 1 |
| Body | {"emails": ["{{Email}}"]} |
The answer is always 200 with a per-address result: enrolled, already-enrolled, suppressed, missing-field, not-found and the other outcomes of enrolment. Running it on a row that column 1 already enrolled is harmless and returns already-enrolled. To save requests, set the column's run condition to rows where column 1 did not return an id.
Test it#
Run both columns on one row first. Then check:
- Prospects in anymail lists the address with source
API. - The campaign's prospect list shows it as enrolled.
GET /api/v1/campaigns/<campaign_id>/prospectsreturns the same. - The integration history (Settings, Integrations, History) has a Clay line for every call that carried
?campaign=, accepted or rejected, with the reason.
The same request from a terminal:
curl -X POST "https://app.anymail.watch/api/v1/prospects?campaign=<campaign_id>" \
-H "Authorization: Bearer $ANYMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"ada@example.com","first_name":"Ada","company_name":"Example Inc"}'
const res = await fetch("https://app.anymail.watch/api/v1/prospects?campaign=<campaign_id>", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANYMAIL_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"email": "ada@example.com", "first_name": "Ada", "company_name": "Example Inc"}),
});
console.log(res.status, await res.text());
import os
import requests
response = requests.request(
"POST",
"https://app.anymail.watch/api/v1/prospects?campaign=<campaign_id>",
headers={
"Authorization": f"Bearer {os.environ['ANYMAIL_TOKEN']}",
},
json={'email': 'ada@example.com', 'first_name': 'Ada', 'company_name': 'Example Inc'},
)
print(response.status_code, response.text)
Receiving signals back#
Clay can receive anymail events through a webhook source. In Clay, create a table with a Webhook source and copy its URL. Then subscribe that URL to the events you want:
curl -X POST "https://app.anymail.watch/api/v1/hooks" \
-H "Authorization: Bearer $ANYMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"event":"offer_read","target_url":"<your Clay webhook URL>"}'
const res = await fetch("https://app.anymail.watch/api/v1/hooks", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANYMAIL_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"event": "offer_read", "target_url": "<your Clay webhook URL>"}),
});
console.log(res.status, await res.text());
import os
import requests
response = requests.request(
"POST",
"https://app.anymail.watch/api/v1/hooks",
headers={
"Authorization": f"Bearer {os.environ['ANYMAIL_TOKEN']}",
},
json={'event': 'offer_read', 'target_url': '<your Clay webhook URL>'},
)
print(response.status_code, response.text)
Each delivery becomes a row with prospect_email, campaign_name and metadata. The events that fit Clay best are new_reply, link_clicked, offer_opened, offer_read and offer_temperature_changed. A column can then act on metadata.to, for example only when an offer reaches hot. See Webhooks for every event and payload.
Limits#
One key allows 60 requests per minute (rate limits). Column 1 and column 2 together take up to two requests per row. Set the HTTP API column's rate limit in Clay to 60 per minute, or 30 per minute if column 2 runs on every row. A 429 row can be rerun from Clay.
Adding a prospect does not send anything. A draft campaign sends nothing until you start it; an active one emails new people on its schedule and sending windows.
Troubleshooting#
| What you see | What to do |
|---|---|
Unknown or unusable campaign. |
The id in the URL is wrong, belongs to another workspace, or the campaign is paused or finished. Copy the URL again from Settings, Integrations, Clay. |
… is already in your workspace. |
Expected for known people. Make sure column 2 runs. |
`custom_fields` … error |
A value is an object or a list. Map a single text column instead. |
| Rows succeed but nobody is emailed | The campaign is still draft, or the person is on the suppression list (column 2 returns suppressed). |