Good connections
Built into your workflow
Connect your own tools to Fondline Campaigns. Find a campaign, add contacts, and receive signed webhook events when conversations move forward.
https://fondline.com/v1Your first API call
Start with a read-only request to find the campaigns your key can access. You’ll need a Fondline account and an API key from Settings → Integrations.
- 01Create a key
Choose its workspace access and save it securely. The key is shown only once.
- 02Find your campaign
Run the request below and choose a campaign ID from the response.
- 03Add your contacts
Send a batch of up to 500 contacts and check each contact’s result.
export FONDLINE_API_KEY="YOUR_API_KEY"
curl https://fondline.com/v1/campaigns \
-H "Authorization: Bearer $FONDLINE_API_KEY"Jump to the POST example. Adding contacts to an active campaign schedules outreach, so use a draft campaign while testing.
Getting a key
Make one in Settings → Integrations. It is shown once, at creation: Fondline stores a hash rather than the key, so a lost key is replaced rather than recovered.
Authorization: Bearer YOUR_API_KEYWhen you make a key you choose what it reaches: one workspace, or every workspace you belong to. The second follows your membership rather than freezing it, so joining a team extends the key and leaving one withdraws it. A key can list campaigns and add contacts. It cannot start or pause a campaign, edit a sequence, read replies, or reach another workspace, so a leaked key costs you unwanted enrollments rather than your mail. Revoking takes effect on the next request.
Keep keys on a server. Anything shipped to a browser or a mobile app is readable by whoever has it. Use one key per system that calls us, named for that system, so revoking one never means guessing what else breaks.
Find a campaign
GET /v1/campaignsUp to 200 campaigns the key can reach, newest first, so you never have to copy an id out of a browser address bar. A key pinned to one workspace sees that workspace; a personal one sees every workspace you are in, and says which is which.
{
"campaigns": [
{
"id": "3a2d2882-0c4b-44b4-984a-1dc4f4a39c82",
"name": "Q4 outreach",
"status": "sending",
"workspace": "NQB8 Team",
"sends_from": "auren@example.com",
"steps": 3,
"contacts": 154
}
]
}status is the word the product uses beside the campaign: draft, sending, paused or completed. sends_from is the inbox its mail actually leaves from, which is worth checking before pointing a script at it.
Add contacts
POST /v1/campaigns/{campaign_id}/contactsUp to 500 contacts per request, as one transaction: it lands or it does not, so a failure halfway leaves nobody half-enrolled. Only email is required; first_name, last_name, company, title and linkedin fill the matching merge fields, and anything in custom becomes a merge field of its own
Send "already_contacted": true for somebody another tool has already been writing to. They are recorded against the campaign and never emailed by it, which is what a migration from another sequencer needs: those people have had two of the three messages already, and sending them again from a new address in a new thread is the worst first impression of a tool they did not know had changed. Dropping them instead would leave Fondline unaware they exist, and the next import would mail them.
curl https://fondline.com/v1/campaigns/3a2d2882-0c4b-44b4-984a-1dc4f4a39c82/contacts \
-H "Authorization: Bearer $FONDLINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{
"email": "ada@analytical.example",
"first_name": "Ada",
"company": "Analytical Engines",
"custom": { "city": "London" }
}
]
}'{
"campaign_id": "3a2d2882-0c4b-44b4-984a-1dc4f4a39c82",
"enrolled": 1,
"results": [
{
"email": "ada@analytical.example",
"status": "enrolled",
"contact_id": "c12acdaf-fa6f-472d-bbf6-a20e94af3d3a"
}
]
}enrolled counts only the people who will actually be emailed as things stand. A contact held for a missing field is not in that number, because counting it would promise a send that is not going to happen.
What each contact comes back as
The response is 200 whenever the request was understood, even if nobody was enrolled: a partial success is the normal case, and a 4xx would misdescribe the people who did go in.
| Status | Means | What to do |
|---|---|---|
| enrolled | In the campaign and due to be emailed | Nothing; sending follows the campaign schedule |
| already_enrolled | They were already in this campaign | Nothing; any new details were merged in |
| needs_fields | In the campaign, held back: the sequence uses a merge field this contact has no value for | Send the missing fields later, or fill them in Fondline; sending resumes by itself |
| suppressed | This address hard-bounced before, or asked not to be contacted, so it was not enrolled | Stop sending it; this one is never overridable |
| invalid_email | Not a usable address | Fix it upstream; it was not stored |
| recorded_not_mailed | In the campaign and stopped, because you sent already_contacted for them | Nothing; they are on record and will never be emailed by this campaign |
| duplicate_in_request | The same address appeared earlier in this request | Nothing; the first one was used |
Sending the same list twice
Safe, and no idempotency key is needed. A contact is identified by campaign and address, so a repeat returns already_enrolled does not repeat the enrollment: no second enrollment, no second email, no reset of where they are in the sequence. New details on a repeat are merged in, which is how you fill a gap the response told you about.
Errors
These describe the request itself. Anything wrong with an individual contact comes back as an outcome above, not as a status code.
| 401 | No key, or the key is not recognised |
| 404 | No campaign with that id in this workspace |
| 413 | More than 500 contacts; split the batch |
| 422 | The body was not understood, or the campaign has no sequence yet |
| 429 | Rate limited; Retry-After says how long |
What it promises
- Suppression is never bypassed. An address that hard-bounced or opted out stays refused, however it arrives. There is no flag to override it.
- Nothing sends immediately. Enrolling schedules. The send window, work-day rule, daily cap and jitter all still apply, so a bulk import does not become a bulk send.
- Nobody is emailed with a blank. A contact missing a merge field the sequence uses is admitted and held, not quietly sent a message with a hole in it.
- Replies still stop the sequence. Contacts added this way are ordinary contacts.
Limits
- 500 contacts per request
- 120 requests a minute, per key
- 1 MB request body
Rate-limited and accepted authenticated requests carry X-RateLimit-Remaining and X-RateLimit-Reset, so you can slow down before being refused rather than after. Past the limit you get 429, and Retry-After says how many seconds to wait
Webhooks
Add an endpoint in Settings → Integrations and Fondline posts to it, so you do not have to ask. Every delivery is signed, retried while your server is unavailable, and switched off after enough consecutive failures.
| messaged | A step went out to a contact |
| replied | Somebody answered, and their sequence stopped |
| bounced_hard | The address is dead and was suppressed |
| bounced_soft | A temporary failure |
| auto_reply | An out-of-office, which does not stop the sequence |
POST https://your-server.example/fondline
Fondline-Signature: t=1788547200,v1=6f2c…
Fondline-Delivery: 8f14e45f-ceea-467a-9b1e-2a4c9d3f1b70
{
"id": "8f14e45f-ceea-467a-9b1e-2a4c9d3f1b70",
"type": "messaged",
"created_at": "2026-09-05T15:12:04Z",
"campaign": { "id": "3a2d2882-…", "name": "Q4 outreach" },
"contact": {
"id": "c12acdaf-…",
"email": "ada@analytical.example",
"first_name": "Ada",
"last_name": "Lovelace"
},
"subject": "Quick question, Ada",
"step": 1
}step is present on messaged and counts from 1. On the others it is absent, because the step is not the thing that happened. There is never a message body: Fondline reads headers only, so the subject is all there is to forward.
Checking the signature
A URL is a secret nobody keeps, so verify before you act. The timestamp is signed with the body, which is what stops a captured delivery being replayed at you later; reject anything older than about five minutes.
import crypto from "node:crypto";
function verifyWebhook(secret, rawBody, header) {
if (typeof header !== "string") return false;
const parts = Object.fromEntries(
header.split(",").map(part => part.trim().split("="))
);
const { t, v1 } = parts;
if (!/^\d+$/.test(t ?? "") || !/^[0-9a-f]{64}$/.test(v1 ?? "")) {
return false;
}
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret)
.update(t + "." + rawBody).digest();
const received = Buffer.from(v1, "hex");
return received.length === expected.length &&
crypto.timingSafeEqual(expected, received);
}Answer 2xx quickly and do your work afterwards. Anything else is a failure and will be retried, backing off from about a minute to an hour, and an endpoint that fails long enough is switched off with the reason recorded where you added it.
Fondline-Delivery is the same on every retry of one event, so it is what to key on if you need to tell a retry from something new.
Not in v1
Starting or pausing a campaign, removing contacts, and creating campaigns. Deciding that mail goes out stays a decision somebody makes in the product, where the consequences are on screen.
Questions: support@fondline.com