Sign in Create account
API reference

AI API: write emails, subject lines and fixes

The AI assistant: write emails, improve text, suggest subject lines, explain deliverability problems and build automations.

The AI assistant writes and improves emails and explains how to fix deliverability problems. Each response uses 1 credit from your plan (Free: 10, Pro: 100, Scale: 1,000 per month). Credits renew on the 1st of every month (UTC).

Besides writing emails, the AI builds automations from a request in plain language, translates messages in inboxes and, when turned on for an inbox, reads each new email and prepares reply drafts. All endpoints require a full-access key.

#How it works

Generating a response can take anywhere from a few seconds to more than a minute, so requests are asynchronous:

  1. You send the POST (for example, POST /ai/subjects). Postal validates the fields, checks that you have credits and responds right away with 202 Accepted and a task: {"object": "ai_task", "id": "…", "status": "pending"}.
  2. You poll GET /ai/tasks/{id} every 1 or 2 seconds. While the AI is working, the status is pending or processing.
  3. When it is ready, the same request returns the full response, with status: "completed", the task_id and the credits balance. If the task fails, the request returns the error, with the matching HTTP status.
Request and polling

cURL

# 1. Request the task (responds 202 with the ID)
curl -X POST https://api.postal.so/ai/subjects \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"content": "Your order #10482 is out for delivery and arrives within 3 business days."}'

# 2. Poll until "status" is "completed" (or an error comes back)
curl https://api.postal.so/ai/tasks/019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const headers = {
  Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  'Content-Type': 'application/json',
};

// 1. Request the task: the response arrives right away, with status 202.
const request = await fetch('https://api.postal.so/ai/subjects', {
  method: 'POST',
  headers,
  body: JSON.stringify({ content: 'Your order #10482 is out for delivery and arrives within 3 business days.' }),
});
const task = await request.json(); // { object: 'ai_task', id, status: 'pending' }

// 2. Poll every 2 seconds until the response is ready (or fails).
async function waitFor(id) {
  for (let attempt = 0; attempt < 90; attempt++) {
    const response = await fetch(`https://api.postal.so/ai/tasks/${id}`, { headers });
    const body = await response.json();

    if (!response.ok) {
      throw new Error(`${body.name}: ${body.message}`); // the task failed
    }

    if (body.status === 'completed') {
      return body;
    }

    await new Promise((resolve) => setTimeout(resolve, 2000));
  }

  throw new Error('The AI took too long to respond.');
}

const result = await waitFor(task.id);
console.log(result.subjects);

Python

import os
import time
import requests

HEADERS = {"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"}

# 1. Request the task: the response arrives right away, with status 202.
task = requests.post(
    "https://api.postal.so/ai/subjects",
    headers=HEADERS,
    json={"content": "Your order #10482 is out for delivery and arrives within 3 business days."},
    timeout=10,
).json()

# 2. Poll every 2 seconds until the response is ready (or fails).
def wait_for(task_id, limit=90):
    for _ in range(limit):
        response = requests.get(f"https://api.postal.so/ai/tasks/{task_id}", headers=HEADERS, timeout=10)
        body = response.json()

        if not response.ok:
            raise RuntimeError(f"{body['name']}: {body['message']}")  # the task failed

        if body["status"] == "completed":
            return body

        time.sleep(2)

    raise TimeoutError("The AI took too long to respond.")

result = wait_for(task["id"])
print(result["subjects"])

PHP

$client = new \GuzzleHttp\Client([
    'base_uri' => 'https://api.postal.so',
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'http_errors' => false,
]);

// 1. Request the task: the response arrives right away, with status 202.
$task = json_decode($client->post('ai/subjects', [
    'json' => ['content' => 'Your order #10482 is out for delivery and arrives within 3 business days.'],
])->getBody(), true);

// 2. Poll every 2 seconds until the response is ready (or fails).
for ($attempt = 0; $attempt < 90; $attempt++) {
    $response = $client->get("ai/tasks/{$task['id']}");
    $body = json_decode($response->getBody(), true);

    if ($response->getStatusCode() >= 400) {
        throw new RuntimeException("{$body['name']}: {$body['message']}"); // the task failed
    }

    if ($body['status'] === 'completed') {
        print_r($body['subjects']);
        break;
    }

    sleep(2);
}

Tasks stay available for 24 hours; after that, the request responds 404.

#Credits

A credit is counted when the AI returns a valid response. Requests rejected right away (invalid fields, no credits) do not use credits, and neither do tasks that fail before the AI responds: busy service (ai_unavailable), request refused by the AI (ai_refused), response too long or in the wrong format (ai_error). The balance also appears in GET /usage (ai_credits).

UseCost
Write, improve, subjects and diagnostics1 credit per response
Automation assistant1 credit per message, even if the AI needs a second pass to fix its own draft
Translating a message and improving the instructions of the inbox agent1 credit per response
The inbox agent1 credit per new email it reads

Every completed response carries the month's balance in credits:

FieldWhat it is
usedCredits used this month (UTC).
limitCredits per month in the plan. null: no limit. 0: the plan does not include AI, and every request responds 429 ai_credits_exhausted.
remainingWhat is left (null with no limit).
resets_atWhen credits renew: the 1st of next month, at midnight UTC.

To read the balance without asking for a response, use GET /usage: ai_credits has the same fields, and features.ai_assistant says whether the server and the plan allow using the AI. The credits of each plan are in GET /plans (limits.ai_credits).

#AI in inboxes

These AI features are on the Received emails and inboxes page. They use the same asynchronous flow (202 and GET /ai/tasks/{id}) and the same credits:

#Write an email

POST/ai/write-email

Full-access key

Writes a subject, preheader and body from a description. In blocks mode, the body comes as visual editor blocks (heading, text, button, divider, spacer, footer); in html mode, you get a complete HTML document and the plain-text version. The final response (shown alongside) arrives through GET /ai/tasks/{id}.

In blocks mode (the default), the response has subject, preheader and blocks, a list of up to 40 blocks without an id (whoever uses them assigns one): heading (html, level from 1 to 3, align), text and footer (html and align), button (label, url, align), divider and spacer (height in pixels, from 4 to 160). Block text only has <b>, <i>, <u>, <a> and <br>, and variables appear as {{{NAME}}}. In html mode, scripts, forms and event handlers are removed from the document.

Request body

  • briefstringrequired
    What the email should say, 10 to 2,000 characters.
  • tonestring
    professional (default), friendly, formal, casual or persuasive.
  • languagestring
    pt-BR (default), en or es.
  • modestring
    blocks (default) or html.

Errors

  • 422validation_error
    A field is missing or out of range.
  • 429ai_credits_exhausted
    The AI credits for the month ran out. They renew on the 1st (UTC).
  • 503ai_not_configured
    The AI assistant is not available.
POST /ai/write-email

cURL

curl -X POST https://api.postal.so/ai/write-email \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "brief": "Let the customer know the order is out for delivery, with the tracking link and the 3 business day estimate.",
    "tone": "friendly",
    "language": "en",
    "mode": "html"
  }'

Node.js

const response = await fetch('https://api.postal.so/ai/write-email', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    brief: 'Let the customer know the order is out for delivery, with the tracking link and the 3 business day estimate.',
    tone: 'friendly',
    language: 'en',
    mode: 'html',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/ai/write-email",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "brief": "Let the customer know the order is out for delivery, with the tracking link and the 3 business day estimate.",
        "tone": "friendly",
        "language": "en",
        "mode": "html",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('ai/write-email', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'brief' => 'Let the customer know the order is out for delivery, with the tracking link and the 3 business day estimate.',
        'tone' => 'friendly',
        'language' => 'en',
        'mode' => 'html',
    ],
]);

$data = json_decode($response->getBody(), true);
202Response (task created)
{
  "object": "ai_task",
  "id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
  "status": "pending"
}
200Final response from GET /ai/tasks/{id}
{
  "object": "ai_email",
  "subject": "Your order is on its way",
  "preheader": "Follow the delivery with the tracking link.",
  "html": "<!DOCTYPE html><html lang=\"en\">…</html>",
  "text": "Hi, {{{NAME}}}! Your order is out for delivery…",
  "task_id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
  "status": "completed",
  "credits": {
    "used": 13,
    "limit": 100,
    "remaining": 87,
    "resets_at": "2026-11-01T00:00:00Z"
  }
}
200Final response in blocks mode
{
  "object": "ai_email",
  "subject": "Your order is on its way",
  "preheader": "Follow the delivery with the tracking link.",
  "blocks": [
    {
      "type": "heading",
      "html": "Your order is on its way",
      "level": 1,
      "align": "left"
    },
    {
      "type": "text",
      "html": "Hi, {{{NAME}}}! Order #10482 is out for delivery and arrives within 3 business days.",
      "align": "left"
    },
    {
      "type": "button",
      "label": "Track order",
      "url": "{{{TRACKING_URL}}}",
      "align": "left"
    },
    {
      "type": "footer",
      "html": "You are receiving this email because you placed an order at Acme Store.",
      "align": "center"
    }
  ],
  "task_id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
  "status": "completed",
  "credits": {
    "used": 13,
    "limit": 100,
    "remaining": 87,
    "resets_at": "2026-11-01T00:00:00Z"
  }
}

#Improve a text

POST/ai/improve-text

Full-access key

Rewrites a passage while keeping links and variables. Accepts simple formatting (<b>, <i>, <u>, <a>, <br>).

Request body

  • htmlstringrequired
    The text, up to 5,000 characters.
  • improvementstringrequired
    shorter, formal, friendly or grammar (only fixes grammar and spelling).

Errors

  • 422validation_error
    A field is missing or out of range.
  • 429ai_credits_exhausted
    The AI credits for the month ran out. They renew on the 1st (UTC).
  • 503ai_not_configured
    The AI assistant is not available.
POST /ai/improve-text

cURL

curl -X POST https://api.postal.so/ai/improve-text \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "hi, your order was shiped and it will get to your house very soon, if you have any question just talk to us.",
    "improvement": "grammar"
  }'

Node.js

const response = await fetch('https://api.postal.so/ai/improve-text', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    html: 'hi, your order was shiped and it will get to your house very soon, if you have any question just talk to us.',
    improvement: 'grammar',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/ai/improve-text",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "html": "hi, your order was shiped and it will get to your house very soon, if you have any question just talk to us.",
        "improvement": "grammar",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('ai/improve-text', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'html' => 'hi, your order was shiped and it will get to your house very soon, if you have any question just talk to us.',
        'improvement' => 'grammar',
    ],
]);

$data = json_decode($response->getBody(), true);
202Response (task created)
{
  "object": "ai_task",
  "id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
  "status": "pending"
}
200Final response from GET /ai/tasks/{id}
{
  "object": "ai_text",
  "html": "Hi! Your order was shipped and will get to your house very soon. If you have any questions, just talk to us.",
  "task_id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
  "status": "completed",
  "credits": {
    "used": 13,
    "limit": 100,
    "remaining": 87,
    "resets_at": "2026-11-01T00:00:00Z"
  }
}

#Suggest subject lines

POST/ai/subjects

Full-access key

Suggests up to 5 different subject lines for the content.

Request body

  • contentstringrequired
    The email content, 10 to 20,000 characters.
  • subjectstring
    The current subject line, up to 200 characters, for the AI to take into account.

Errors

  • 422validation_error
    A field is missing or out of range.
  • 429ai_credits_exhausted
    The AI credits for the month ran out. They renew on the 1st (UTC).
  • 503ai_not_configured
    The AI assistant is not available.
POST /ai/subjects

cURL

curl -X POST https://api.postal.so/ai/subjects \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Your order #10482 is out for delivery and arrives within 3 business days. Follow it with the tracking link."
  }'

Node.js

const response = await fetch('https://api.postal.so/ai/subjects', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    content: 'Your order #10482 is out for delivery and arrives within 3 business days. Follow it with the tracking link.',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/ai/subjects",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "content": "Your order #10482 is out for delivery and arrives within 3 business days. Follow it with the tracking link.",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('ai/subjects', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'content' => 'Your order #10482 is out for delivery and arrives within 3 business days. Follow it with the tracking link.',
    ],
]);

$data = json_decode($response->getBody(), true);
202Response (task created)
{
  "object": "ai_task",
  "id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
  "status": "pending"
}
200Final response from GET /ai/tasks/{id}
{
  "object": "ai_subjects",
  "subjects": [
    "Your order #10482 is out for delivery",
    "Arrives within 3 business days",
    "Track your order in real time",
    "Good news: your order is on its way",
    "Want to know where your order is?"
  ],
  "task_id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
  "status": "completed",
  "credits": {
    "used": 13,
    "limit": 100,
    "remaining": 87,
    "resets_at": "2026-11-01T00:00:00Z"
  }
}

#Explain the diagnostics

POST/ai/emails/{id}/fix-insights

Full-access key

Explains how to fix the checks that failed in the deliverability diagnostics of a sent email. The text is written in the language of the language parameter (pt-BR, en or es); without it, in the language of the request: Accept-Language: pt-BR or en, and English when the API key sends no header. If the email has no problems, the task ends with 422 validation_error, without using credits.

Path parameters

  • idstringrequired
    The email ID.

Request body (optional)

  • languagestring
    Language of the text: pt-BR, en or es. Default: the language of the request (Accept-Language), English without the header.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
  • 422validation_error
    language is not one of pt-BR, en and es, or the email has no failing checks.
  • 429ai_credits_exhausted
    The AI credits for the month ran out. They renew on the 1st (UTC).
  • 503ai_not_configured
    The AI assistant is not available.
POST /ai/emails/{id}/fix-insights

cURL

curl -X POST https://api.postal.so/ai/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/fix-insights \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "language": "en"
  }'

Node.js

const response = await fetch('https://api.postal.so/ai/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/fix-insights', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    language: 'en',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/ai/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/fix-insights",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "language": "en",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('ai/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/fix-insights', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'language' => 'en',
    ],
]);

$data = json_decode($response->getBody(), true);
202Response (task created)
{
  "object": "ai_task",
  "id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
  "status": "pending"
}
200Final response from GET /ai/tasks/{id}
{
  "object": "ai_insights",
  "summary": "A link without HTTPS can make the email look unsafe to spam filters.",
  "fixes": [
    {
      "code": "https_links",
      "title": "Use HTTPS on every link",
      "steps": [
        "Replace http:// with https:// in the tracking link.",
        "Check that the site opens with a valid certificate."
      ]
    }
  ],
  "task_id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
  "status": "completed",
  "credits": {
    "used": 13,
    "limit": 100,
    "remaining": 87,
    "resets_at": "2026-11-01T00:00:00Z"
  }
}

#Build an automation with the AI

POST/ai/automations/{id}/assist

Full-access key

The assistant of the automations builder: you send the conversation so far (ending with the person's request) and the current draft, and it answers with a sentence and the changes to the draft (trigger, steps, name, re-entry), already checked by the same rules as POST /automations. Nothing is saved, activated or sent: for it to count, apply the returned definition to your editor and save it with PATCH /automations/{id}. The automation in the path has to exist (it can be a draft); it gives the context, and the definition in the body is what counts. The final response arrives through GET /ai/tasks/{id}.

The assistant knows what the team has (properties, segments, topics, templates, forms, tags, broadcasts, recent events and sending domains). If the draft it builds breaks a rule, it makes a second pass to fix it; the changes that are still invalid are left out, in skipped. Both passes cost 1 credit.

The sentences (reply and the summary fields) come in Portuguese with Accept-Language: pt-BR; without the header, in English.

Path parameters

  • idstringrequired
    The automation ID.

Request body

  • messagesobject[]required
    The conversation, oldest first, up to 50 messages (only the last 12 reach the AI). The last one has to be from the user.
    • rolestringrequired
      user or assistant.
    • contentstringrequired
      The text, up to 4,000 characters (2,000 on the last message, which is the request).
  • definitionobjectrequired
    The current draft, in the automation format. Up to 64 KB.
    • namestring | null
      Name, up to 255 characters.
    • triggerobjectrequired
      The trigger, with type.
    • reentrystring
      once or every_time.
    • stepsobject[]required
      The steps. It can be an empty list, but it has to be sent.
  • notesstring[]
    Up to 10 notes of up to 500 characters about what the person did in the editor since the last answer, such as “I created the plan property” or “I undid the changes”.
Response fieldWhat it is
definitionThe draft with the changes already applied, normalized (steps get an id and every config field). If nothing changed, it is what you sent.
changedtrue if the definition differs from what you sent.
operationsThe changes applied, in order: op (set_trigger, add_step, update_step, remove_step, move_step, set_entry_filters, set_reentry or rename_automation), a summary to show the person and the step_id of the affected step (or null).
pending_actionsResources the flow needs that do not exist yet: create_contact_property, create_segment or create_topic, each with id, summary and payload. The API does not create anything: after the person confirms, create the resource in properties, segments or topics and replace the temporary new:<ref> ID in the definition with the created ID.
skippedChanges the AI proposed that could not be applied, with the reason in message.
issuesWhat is still missing or wrong in the definition (each with step_id and message, as in GET /automations/{id}). Pending items only block activation.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
  • 422validation_error
    messages empty, the last message is not from the user or is over 2,000 characters, definition without trigger.type or steps, or larger than 64 KB.
  • 429ai_credits_exhausted
    The AI credits for the month ran out. They renew on the 1st (UTC).
  • 503ai_not_configured
    The AI assistant is not available.
POST /ai/automations/{id}/assist

cURL

curl -X POST https://api.postal.so/ai/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/assist \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Accept-Language: en" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      {
        "role": "user",
        "content": "When someone starts the free trial, send the welcome email. If they open it within 2 days, put them in an Engaged segment; if not, send a reminder."
      }
    ],
    "definition": {
      "name": "Free trial welcome",
      "trigger": {
        "type": "event",
        "event": "trial.started"
      },
      "reentry": "once",
      "steps": []
    }
  }'

Node.js

const response = await fetch('https://api.postal.so/ai/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/assist', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
    'Accept-Language': 'en',
  },
  body: JSON.stringify({
    messages: [
      {
        role: 'user',
        content: 'When someone starts the free trial, send the welcome email. If they open it within 2 days, put them in an Engaged segment; if not, send a reminder.',
      },
    ],
    definition: {
      name: 'Free trial welcome',
      trigger: {
        type: 'event',
        event: 'trial.started',
      },
      reentry: 'once',
      steps: [],
    },
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/ai/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/assist",
    headers={
        "Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}",
        "Accept-Language": "en",
    },
    json={
        "messages": [
            {
                "role": "user",
                "content": "When someone starts the free trial, send the welcome email. If they open it within 2 days, put them in an Engaged segment; if not, send a reminder.",
            },
        ],
        "definition": {
            "name": "Free trial welcome",
            "trigger": {
                "type": "event",
                "event": "trial.started",
            },
            "reentry": "once",
            "steps": [],
        },
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('ai/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/assist', [
    'headers' => [
        'Authorization' => 'Bearer '.getenv('POSTAL_API_KEY'),
        'Accept-Language' => 'en',
    ],
    'json' => [
        'messages' => [
            [
                'role' => 'user',
                'content' => 'When someone starts the free trial, send the welcome email. If they open it within 2 days, put them in an Engaged segment; if not, send a reminder.',
            ],
        ],
        'definition' => [
            'name' => 'Free trial welcome',
            'trigger' => [
                'type' => 'event',
                'event' => 'trial.started',
            ],
            'reentry' => 'once',
            'steps' => [],
        ],
    ],
]);

$data = json_decode($response->getBody(), true);
202Response (task created)
{
  "object": "ai_task",
  "id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
  "status": "pending"
}
200Final response from GET /ai/tasks/{id}
{
  "object": "ai_automation_assist",
  "reply": "I built the flow: it sends the welcome email, waits up to 2 days for it to be opened and, if it was, puts the contact in the Engaged segment; if not, it sends a reminder. You still need to choose the reminder's template, and the Engaged segment has yet to be created.",
  "definition": {
    "name": "Free trial welcome",
    "trigger": {
      "type": "event",
      "event": "trial.started",
      "filters": []
    },
    "reentry": "once",
    "steps": [
      {
        "id": "welcome",
        "type": "send_email",
        "config": {
          "template_id": "0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a",
          "from": null,
          "subject": null,
          "reply_to": null,
          "detect_replies": false,
          "variables": {}
        }
      },
      {
        "id": "wait_open",
        "type": "wait_for",
        "config": {
          "step_id": "welcome",
          "activity": "opened",
          "link_contains": null,
          "timeout": {
            "amount": 2,
            "unit": "days"
          }
        },
        "branches": {
          "yes": [
            {
              "id": "tag_engaged",
              "type": "update_contact",
              "config": {
                "actions": [
                  {
                    "type": "add_to_segment",
                    "segment_id": "new:engaged"
                  }
                ]
              }
            }
          ],
          "no": [
            {
              "id": "reminder",
              "type": "send_email",
              "config": {
                "template_id": null,
                "from": null,
                "subject": null,
                "reply_to": null,
                "detect_replies": false,
                "variables": {}
              }
            }
          ]
        }
      }
    ]
  },
  "changed": true,
  "operations": [
    {
      "op": "set_trigger",
      "summary": "Trigger set: Event received (trial.started).",
      "step_id": null
    },
    {
      "op": "add_step",
      "summary": "Added step: Send email (Trial welcome).",
      "step_id": "welcome"
    },
    {
      "op": "add_step",
      "summary": "Added step: Wait until (opened, at most 2 days).",
      "step_id": "wait_open"
    },
    {
      "op": "add_step",
      "summary": "Added step: Update contact.",
      "step_id": "tag_engaged"
    },
    {
      "op": "add_step",
      "summary": "Added step: Send email.",
      "step_id": "reminder"
    }
  ],
  "pending_actions": [
    {
      "id": "action_1",
      "type": "create_segment",
      "summary": "Create the segment “Engaged”.",
      "payload": {
        "ref": "new:engaged",
        "name": "Engaged",
        "description": "Opened the welcome email"
      }
    }
  ],
  "skipped": [],
  "issues": [
    {
      "step_id": "reminder",
      "message": "Choose the template to send."
    }
  ],
  "task_id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
  "status": "completed",
  "credits": {
    "used": 13,
    "limit": 100,
    "remaining": 87,
    "resets_at": "2026-11-01T00:00:00Z"
  }
}

#Get a task

GET/ai/tasks/{id}

Full-access key

While the AI is working, it returns only the status (pending or processing). When it finishes, it returns the response to the request (the same fields shown in each endpoint above) with task_id, status: "completed" and credits. If the task failed, the response is the error, in the standard format and with the HTTP status of the error. Poll every 1 or 2 seconds; most responses are ready within a minute.

Path parameters

  • idstringrequired
    The id returned by the POST.

Errors

  • 404not_found
    The task does not exist, belongs to another team or is older than 24 hours.
  • 422ai_refused
    The AI refused the request. Rewrite it and try again.
  • 422validation_error
    The request could not be fulfilled (for example, the email has no problems to fix).
  • 429ai_credits_exhausted
    The credits ran out before the task ran.
  • 502ai_error
    The AI did not return a valid response or took too long. Try again.
  • 503ai_unavailable
    The AI service is busy. Try again in a moment.
GET /ai/tasks/{id}

cURL

curl https://api.postal.so/ai/tasks/019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/ai/tasks/019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/ai/tasks/019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('ai/tasks/019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response (still processing)
{
  "object": "ai_task",
  "id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
  "status": "processing"
}
200Response (ready)
{
  "object": "ai_subjects",
  "subjects": [
    "Your order #10482 is out for delivery",
    "Arrives within 3 business days"
  ],
  "task_id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
  "status": "completed",
  "credits": {
    "used": 13,
    "limit": 100,
    "remaining": 87,
    "resets_at": "2026-11-01T00:00:00Z"
  }
}
503Response (task failed)
{
  "statusCode": 503,
  "name": "ai_unavailable",
  "message": "The AI assistant is busy right now. Try again in a moment."
}