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"
    }
  ]
}
  • type accepts List or Segment (case-sensitive). Omit it to get both. Anything else is a 400 — invalid type "segment", valid values are "List" and "Segment".
  • web_url points at the list page or the segment page depending on type, the same way the console routes them.
  • An organization with nothing to list gets "data": [], never null.
  • templates/get-email-templates needs sending_access; contacts/get-groups accepts sending_access or contacts_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:

ValueSubject used
manual (default when omitted)The subject you send, or the template's own subject if you omit subject.
first_articleThe 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_time is a UNIX timestamp in seconds. Omit it (or send 0) to start sending immediately. A timestamp more than 60 seconds in the past is rejected with 400.
  • sender_email's domain must be one of your verified sending domains; display_name is the friendly From name.
  • send_group_id must already exist. preheader and reply_to are optional; everything else is required.
  • email_html is 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_html only works with subject_gen_type: "manual". Raw HTML has no article components, so there is no article title to take a subject from — first_article alongside email_html is a 400.
  • template_id must belong to your own organization. Someone else's id comes back as 400 template_id not found in this organization, not 500 and not a leak of whether it exists.
  • template_id must be a real template, not one of the internal per-send snapshots. Those are excluded from templates/get-email-templates for exactly this reason; passing one anyway returns 400 template_id refers to an internal per-send snapshot and cannot be used as a source template. A send_template_id handed back by an earlier call is one of those snapshots — don't feed it into the next send; pass the same template_id you used the first time.
  • subject is required on the template_id path too, including with subject_gen_type: "first_article" — it is what goes out if the template renders no article to take a title from. Omitting it is a 400 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_status is now running.
  • reason: "already_running" — one was already in flight; nothing new was started.
  • reason: "recently_refreshed" — a successful refresh is within stale_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_sent in 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_sent is still 0 the next day, send us the flow_id instead 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 ...
© Aigeon.ai 2025
All Rights Reserved