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:
- You send the
POST(for example,POST /ai/subjects). Postal validates the fields, checks that you have credits and responds right away with202 Acceptedand a task:{"object": "ai_task", "id": "…", "status": "pending"}. - You poll
GET /ai/tasks/{id}every 1 or 2 seconds. While the AI is working, thestatusispendingorprocessing. - When it is ready, the same request returns the full response, with
status: "completed", thetask_idand thecreditsbalance. If the task fails, the request returns the error, with the matching HTTP status.
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).
| Use | Cost |
|---|---|
| Write, improve, subjects and diagnostics | 1 credit per response |
| Automation assistant | 1 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 agent | 1 credit per response |
| The inbox agent | 1 credit per new email it reads |
Every completed response carries the month's balance in credits:
| Field | What it is |
|---|---|
used | Credits used this month (UTC). |
limit | Credits per month in the plan. null: no limit. 0: the plan does not include AI, and every request responds 429 ai_credits_exhausted. |
remaining | What is left (null with no limit). |
resets_at | When 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:
POST /inboxes/{id}/threads/{thread_id}/messages/{message_id}/translatetranslates a message of the conversation, received or sent. The result isai_translation.POST /inboxes/{id}/agent/improve-instructionsrewrites the agent's instructions more clearly, without saving. The result isagent_instructions.- The inbox agent has no endpoint of its own: turned on in
PATCH /inboxes/{id}, it reads each new email and uses 1 credit per email. It only creates drafts for a person to review and never sends on its own.
#Write an email
POST/ai/write-email
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
briefstringrequiredWhat the email should say, 10 to 2,000 characters.tonestringprofessional(default),friendly,formal,casualorpersuasive.languagestringpt-BR(default),enores.modestringblocks(default) orhtml.
Errors
- 422
validation_errorA field is missing or out of range. - 429
ai_credits_exhaustedThe AI credits for the month ran out. They renew on the 1st (UTC). - 503
ai_not_configuredThe AI assistant is not available.
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);
{
"object": "ai_task",
"id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"status": "pending"
}
{
"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"
}
}
{
"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
Rewrites a passage while keeping links and variables. Accepts simple formatting (<b>, <i>, <u>, <a>, <br>).
Request body
htmlstringrequiredThe text, up to 5,000 characters.improvementstringrequiredshorter,formal,friendlyorgrammar(only fixes grammar and spelling).
Errors
- 422
validation_errorA field is missing or out of range. - 429
ai_credits_exhaustedThe AI credits for the month ran out. They renew on the 1st (UTC). - 503
ai_not_configuredThe AI assistant is not available.
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);
{
"object": "ai_task",
"id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"status": "pending"
}
{
"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
Suggests up to 5 different subject lines for the content.
Request body
contentstringrequiredThe email content, 10 to 20,000 characters.subjectstringThe current subject line, up to 200 characters, for the AI to take into account.
Errors
- 422
validation_errorA field is missing or out of range. - 429
ai_credits_exhaustedThe AI credits for the month ran out. They renew on the 1st (UTC). - 503
ai_not_configuredThe AI assistant is not available.
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);
{
"object": "ai_task",
"id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"status": "pending"
}
{
"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
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
idstringrequiredThe email ID.
Request body (optional)
languagestringLanguage of the text:pt-BR,enores. Default: the language of the request (Accept-Language), English without the header.
Errors
- 404
not_foundThe resource does not exist or belongs to another team. - 422
validation_errorlanguageis not one ofpt-BR,enandes, or the email has no failing checks. - 429
ai_credits_exhaustedThe AI credits for the month ran out. They renew on the 1st (UTC). - 503
ai_not_configuredThe AI assistant is not available.
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);
{
"object": "ai_task",
"id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"status": "pending"
}
{
"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
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
idstringrequiredThe automation ID.
Request body
messagesobject[]requiredThe conversation, oldest first, up to 50 messages (only the last 12 reach the AI). The last one has to be from the user.rolestringrequireduserorassistant.contentstringrequiredThe text, up to 4,000 characters (2,000 on the last message, which is the request).
definitionobjectrequiredThe current draft, in the automation format. Up to 64 KB.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 field | What it is |
|---|---|
definition | The draft with the changes already applied, normalized (steps get an id and every config field). If nothing changed, it is what you sent. |
changed | true if the definition differs from what you sent. |
operations | The 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_actions | Resources 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. |
skipped | Changes the AI proposed that could not be applied, with the reason in message. |
issues | What 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
- 404
not_foundThe resource does not exist or belongs to another team. - 422
validation_errormessagesempty, the last message is not from the user or is over 2,000 characters,definitionwithouttrigger.typeorsteps, or larger than 64 KB. - 429
ai_credits_exhaustedThe AI credits for the month ran out. They renew on the 1st (UTC). - 503
ai_not_configuredThe AI assistant is not available.
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);
{
"object": "ai_task",
"id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"status": "pending"
}
{
"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}
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
idstringrequiredTheidreturned by thePOST.
Errors
- 404
not_foundThe task does not exist, belongs to another team or is older than 24 hours. - 422
ai_refusedThe AI refused the request. Rewrite it and try again. - 422
validation_errorThe request could not be fulfilled (for example, the email has no problems to fix). - 429
ai_credits_exhaustedThe credits ran out before the task ran. - 502
ai_errorThe AI did not return a valid response or took too long. Try again. - 503
ai_unavailableThe AI service is busy. Try again in a moment.
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);
{
"object": "ai_task",
"id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"status": "processing"
}
{
"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"
}
}
{
"statusCode": 503,
"name": "ai_unavailable",
"message": "The AI assistant is busy right now. Try again in a moment."
}