cURL examples
cURL one-liners for quick probing and scripts.
Shell setup
export AIGEON_API_KEY=sk_...
export AIGEON_BASE=https://api.aigeon.ai
Health check
curl "$AIGEON_BASE/health"
# {"code":0,"status":"success","message":"ok","data":"ok"}
Verify your API key
curl "$AIGEON_BASE/public/api/v1/token/check" \
-H "X-Api-Token: $AIGEON_API_KEY"
Successful keys return code: 200 and the organization_id they're bound to.
Add a contact
curl -X POST "$AIGEON_BASE/public/api/v1/contacts/add-group-members" \
-H "X-Api-Token: $AIGEON_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"send_group_id": "grp_8Kj2mNp4qRs7Tv9x",
"group_members": [{"email": "alice@example.com", "first_name": "Alice"}]
}'
Look up template and group ids
A send has to name an existing send group, and — if you're sending from a saved template rather than raw HTML — an existing template. Both are listable, so your integration never has to hard-code ids copied out of the console by hand.
curl -G "$AIGEON_BASE/public/api/v1/templates/get-email-templates" \
-H "X-Api-Token: $AIGEON_API_KEY"
{
"code": 0,
"status": "success",
"message": "",
"data": [
{
"template_id": "tem_5Fp9kLm2wXj4Nq7T",
"template_name": "Daily digest",
"template_type": "native",
"create_time": 1774915200,
"update_time": 1777593600,
"web_url": "https://www.aigeon.ai/design-library/your-designs/tem_5Fp9kLm2wXj4Nq7T/editor"
}
]
}
The list covers the templates you can actually pick from. The per-send snapshots Aigeon creates internally for every scheduled send are filtered out, so the list stays the size of your template library rather than the size of your send history.
web_url opens that entry in the web console. It is a convenience for your own logs and admin screens — a person can click straight through to whatever your integration just used — and it requires a console login, so it is not a link to hand to your subscribers.
curl -G "$AIGEON_BASE/public/api/v1/contacts/get-groups" \
-H "X-Api-Token: $AIGEON_API_KEY" \
--data-urlencode "type=Segment"
{
"code": 0,
"status": "success",
"message": "",
"data": [
{
"send_group_id": "grp_8Kj2mNp4qRs7Tv9x",
"send_group_name": "Engaged readers",
"type": "Segment",
"members_count": 128394,
"create_time": 1772323200,
"update_time": 1777593600,
"web_url": "https://www.aigeon.ai/contact/segments/grp_8Kj2mNp4qRs7Tv9x"
}
]
}
typeacceptsListorSegment(case-sensitive). Omit it to get both. Anything else is a400—invalid type "segment", valid values are "List" and "Segment".web_urlpoints at the list page or the segment page depending ontype, the same way the console routes them.- An organization with nothing to list gets
"data": [], nevernull. templates/get-email-templatesneedssending_access;contacts/get-groupsacceptssending_accessorcontacts_access.
Create and schedule a one-time send
Requires a key with sending_access. This single call creates the send and schedules it. The body carries exactly one content source: email_html (a complete raw HTML document) or template_id (a template you already have, from the list above). Sending both, or neither, is a 400.
curl -X POST "$AIGEON_BASE/public/api/v1/campaign/create-and-schedule" \
-H "X-Api-Token: $AIGEON_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"single_send_name": "Friday Digest 2026-05-01",
"send_group_id": "grp_8Kj2mNp4qRs7Tv9x",
"subject": "Your Friday digest",
"preheader": "Five stories worth your morning",
"sender_email": "news@example.com",
"display_name": "Example Daily",
"reply_to": "hello@example.com",
"send_time": 1777662000,
"email_html": "<html><body><h1>Hello</h1></body></html>"
}'
Sending from an existing template instead
Swap email_html for template_id and, optionally, let the template decide the subject line:
curl -X POST "$AIGEON_BASE/public/api/v1/campaign/create-and-schedule" \
-H "X-Api-Token: $AIGEON_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"single_send_name": "Friday Digest 2026-05-01",
"send_group_id": "grp_8Kj2mNp4qRs7Tv9x",
"template_id": "tem_5Fp9kLm2wXj4Nq7T",
"subject_gen_type": "first_article",
"sender_email": "news@example.com",
"display_name": "Example Daily",
"send_time": 1777662000
}'
Your template is never modified. Scheduling takes a private per-send copy of it — the same thing the console does for every send you build by hand — and that copy is what goes out. The copy is one of the internal per-send snapshots, so it never appears in templates/get-email-templates: the list stays the size of your template library, not the size of your send history.
Two consequences worth coding against:
- Editing the original template afterwards does not change an already-scheduled send. The send is pinned to the copy taken when you scheduled it. To pick up an edit, cancel the send and schedule a new one. There is no cancel endpoint in the API — cancelling is done in the console, and a scheduled send locks 15 minutes before
send_time: past that point the console will neither edit nor cancel it. - Dynamic content still resolves at send time. The copy keeps the template's article query, so the send picks up that day's articles and, with
subject_gen_type: "first_article", that day's headline. Copying freezes the layout and the query, not the articles they pull in.
The response returns both ids. template_id is the id you passed in, echoed back unchanged — keep using it to refer to your own template, and it stays findable in templates/get-email-templates. send_template_id is the per-send copy, which identifies this one send's content and is deliberately absent from that list.
subject_gen_type picks where the subject line comes from:
| Value | Subject used |
|---|---|
manual (default when omitted) | The subject you send, or the template's own subject if you omit subject. |
first_article | The title of the first article the template renders, resolved at send time rather than when you schedule. Any subject you send is ignored. |
The value is matched exactly and anything else is rejected. first_article, firstarticle, FIRST_ARTICLE and "" are four different things to a string comparison, and only the first is valid — you get 400 invalid subject_gen_type "FIRST_ARTICLE", valid values are "manual" and "first_article" rather than a silently different subject line.
Field notes:
send_timeis a UNIX timestamp in seconds. Omit it (or send0) to start sending immediately. A timestamp more than 60 seconds in the past is rejected with400.sender_email's domain must be one of your verified sending domains;display_nameis the friendly From name.send_group_idmust already exist.preheaderandreply_toare optional; everything else is required.email_htmlis the complete HTML document — it must contain an<html>tag, and it is snapshotted into a template of its own, so later edits to your source HTML don't affect a send that's already scheduled.email_htmlonly works withsubject_gen_type: "manual". Raw HTML has no article components, so there is no article title to take a subject from —first_articlealongsideemail_htmlis a400.template_idmust belong to your own organization. Someone else's id comes back as400 template_id not found in this organization, not500and not a leak of whether it exists.template_idmust be a real template, not one of the internal per-send snapshots. Those are excluded fromtemplates/get-email-templatesfor exactly this reason; passing one anyway returns400 template_id refers to an internal per-send snapshot and cannot be used as a source template. Asend_template_idhanded back by an earlier call is one of those snapshots — don't feed it into the next send; pass the sametemplate_idyou used the first time.subjectis required on thetemplate_idpath too, including withsubject_gen_type: "first_article"— it is what goes out if the template renders no article to take a title from. Omitting it is a400 subject is required. Your template's own subject is not used: a template carries a subject field, but nothing reads it at send time, so the send takes its subject and preheader from this request and nowhere else.
On success data carries flow_id, campaign_id, template_id, send_template_id, status and send_time. On the email_html path there is no template of yours to echo back, so template_id and send_template_id are both the snapshot created from your HTML. For a one-time send campaign_id is the same value as flow_id — that's expected, not a bug; the two only diverge for multi-step flows, where each node gets its own campaign id.
IDs follow fixed prefixes, which is handy when validating input on your side: flow_ for flows and one-time campaigns, tem_ for templates, grp_ for send groups — each followed by 16 base62 characters.
Check code, not just the HTTP status. If the send is created but scheduling fails, the response is still 200 OK with a non-zero code, and the campaign is left as a draft you can start from the console:
{
"code": 4003,
"status": "failed",
"message": "Schedule step failed, the campaign is saved as draft. You can restart it on Aigeon website",
"data": {
"flow_id": "flow_3Bn6xQw1zYc8Hd5R",
"campaign_id": "flow_3Bn6xQw1zYc8Hd5R",
"template_id": "tem_5Fp9kLm2wXj4Nq7T",
"send_template_id": "tem_Qw1zYc8Hd5R3Bn6x",
"status": "draft"
}
}
One failure worth retrying rather than reporting
Before the send is created, the template is rendered once as a pre-flight check. That render fetches whatever the template pulls from — for a dynamic template, your own feed. If that fetch times out, the pre-flight fails and the request comes back as:
HTTP 400, code 101: template_id cannot be rendered: ErrCodeSendRequestTimeout
Despite the 400, this one is not a problem with your request — it is a transient upstream timeout, and the same call usually succeeds on a retry a few minutes later. Retry once before treating it as a permanent failure or alerting a human. A render failure caused by the template itself (a malformed URL, a component that cannot resolve) returns the same shape but keeps failing, so cap the retries rather than looping.
Sending to a segment
A segment's membership isn't evaluated when the send goes out — it's evaluated when the segment is refreshed, and the send then goes to the most recent refreshed version. So a segment send is three calls, in this order:
# 1. find the segment
curl -G "$AIGEON_BASE/public/api/v1/contacts/get-groups" \
-H "X-Api-Token: $AIGEON_API_KEY" \
--data-urlencode "type=Segment"
# 2. refresh it, so the membership matches today's data
curl -X POST "$AIGEON_BASE/public/api/v1/contacts/refresh-segment" \
-H "X-Api-Token: $AIGEON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"send_group_id": "grp_8Kj2mNp4qRs7Tv9x", "stale_hours": 6}'
# 3. create the send against it
curl -X POST "$AIGEON_BASE/public/api/v1/campaign/create-and-schedule" ...
stale_hours is required and must be at least 6; omitting it is a 400 — stale_hours must be at least 6. It is a no-op guard, not a schedule: if a refresh already succeeded within that many hours, the call returns 200 without re-running anything.
The response tells you which of those happened, and doubles as the poll target:
{
"code": 0,
"status": "success",
"message": "",
"data": {
"send_group_id": "grp_8Kj2mNp4qRs7Tv9x",
"type": "Segment",
"segment_task_status": "success",
"segment_latest_available_version": 1,
"last_refreshed_at": 1777593600,
"triggered": false,
"reason": "recently_refreshed"
}
}
reason: "triggered"— a refresh was started;segment_task_statusis nowrunning.reason: "already_running"— one was already in flight; nothing new was started.reason: "recently_refreshed"— a successful refresh is withinstale_hours; nothing was started, and the existing membership is what your send will use.
The refresh is asynchronous, so there is no "wait until done" call. Poll by re-issuing the same request and watching segment_latest_available_version advance while segment_task_status reaches success. A segment with segment_latest_available_version: 0 has no usable membership yet — that is the case to gate on before creating a send.
Skipping step 2 fails silently, in two different ways. If the segment was refreshed at some point in the past, your send goes to that stale membership. If it has never been refreshed, the send is created and scheduled successfully and reaches zero recipients — no error, no warning, and status still moves to sent. Refresh first, every time; it's cheap and it's the only way to know who you're sending to.
Lists (type: "List") have no such step — their membership is whatever you last added with contacts/add-group-members.
A 200 is not a delivery receipt
create-and-schedule returning 200 with code: 0 means Aigeon accepted and queued the send. It does not mean any mail has been handed to an inbox, and it does not mean any mail ever will be. Between that response and a delivered message sit the recipient filters (unsubscribed, previously bounced, suppressed at the mail-provider level), the render step, and the sending pipeline itself — and several of those drop a recipient without producing an error anywhere your integration can see.
Two things people reasonably mistake for delivery, which aren't:
status: "scheduled"in the create response. That is the state of the campaign record, written before anything is sent.status: "sent"on the campaign afterwards. That flips once the pipeline has processed the campaign, including when every recipient in it was filtered out.
The check that actually distinguishes them is the stats endpoint, which is driven by events from the mail pipeline rather than by the campaign record:
curl -G "$AIGEON_BASE/public/api/v1/stats/get-flow-stats" \
-H "X-Api-Token: $AIGEON_API_KEY" \
--data-urlencode "flow_id=flow_3Bn6xQw1zYc8Hd5R" \
--data-urlencode "organization_id=your_org_id"
Read data.total_metrics — total_sent, total_bounce, total_open. total_sent is the count that actually reflects mail leaving the pipeline, rather than the state of the campaign record.
These counters lag, and the lag is long. They're assembled from delivery events, not written at send time: expect total_sent to still read 0 tens of minutes after send_time on a send that is going out perfectly normally. Treat a fresh zero as "not known yet", not as a failure, and poll rather than checking once. Requires a key with analytics_access.
Two things follow for your integration:
- Don't gate anything on
total_sentin the minutes after a send — you'll be reading a counter that hasn't been populated yet. - Never retry a send because the count looks low. A second campaign against the same audience double-sends if the first one was merely still in flight. If
total_sentis still 0 the next day, send us theflow_idinstead of re-issuing the call.
One more thing the counters won't tell you: delivered is not the same as inboxed. A message can be accepted, delivered, and still land in the recipient's spam folder — that shows up as a healthy total_sent with poor opens, not as an error. If opens are far below what you expect, the question is sending reputation for your sending domain, not the API call.
Send through a flow
curl -X POST "$AIGEON_BASE/public/api/v1/flow/send-email-through-flow" \
-H "X-Api-Token: $AIGEON_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"flow_id": "flow_3Bn6xQw1zYc8Hd5R",
"contact_email": "alice@example.com",
"context": { "order_id": "ord_123" }
}'
Pull analytics
curl -G "$AIGEON_BASE/public/api/v1/analytics/metrics/get-email-event-metric" \
-H "X-Api-Token: $AIGEON_API_KEY" \
--data-urlencode "event_name=emailClick" \
--data-urlencode "start_date=2026-04-01" \
--data-urlencode "end_date=2026-04-30"
Debug an unexpected response
Add -i (show response headers) and --fail-with-body (print body on HTTP errors):
curl -i --fail-with-body ...
