Signup forms API
Signup forms, hosted or on your own site, with double opt-in.
Signup forms put people in your audience: each submission creates (or updates) a contact, adds it to the form's segment and subscribes it to the chosen topics. You can use the ready-made page, hosted at https://forms.postal.so/{slug}, or submit from your own site (HTML or JavaScript), as Public submission shows. With double opt-in, the person receives a confirmation email and only joins the audience after clicking the link.
A form is made of pages and blocks (questions, with logic between pages), has closing settings, email alerts for each submission, visit and conversion statistics and a list of submissions across all forms. Every endpoint on this page uses a full-access API key; the public submission uses no key.
#Pages and blocks
pages is the list of the form's pages (1 to 10), each with its own id (up to 32 lowercase letters, numbers, - or _; submit is reserved), title, description, media, blocks (1 to 30 per page and 60 per form) and logic. Each block's id (a lowercase letter followed by up to 63 lowercase letters, numbers or _) is also the key of the answer in the submission. Every form has one email block, always required, always visible and on a page every answer goes through.
Type (type) | What it is |
|---|---|
heading, paragraph, image | Content: shown, but takes no answer. image requires media. |
email, first_name, last_name | Fields of the contact itself. |
short_text, long_text, phone | Text (up to 255, 5,000 and 32 characters). |
number, date | Number (with optional min and max) and a YYYY-MM-DD date. |
single_choice, multiple_choice, dropdown | Choice: require 1 to 50 options (value, label, and image_url or illustration on the first two). They accept randomize and other (the "Other" option, whose answer becomes other:text); picture ones accept columns (2, 3 or 4). |
yes_no, consent | Yes or no, and the consent checkbox (true when checked; the submission keeps the date and IP of the consent). |
rating, nps | A rating from 1 up to max (3 to 10; default 5) and an NPS from 0 to 10. |
hidden | A value that is not shown: on the hosted page, it comes from the query string (for example, ?utm_source=blog). |
Every block accepts label (required, except for content and hidden blocks), description, required, media and visible_if. placeholder applies to text, email, number and dropdown fields. property saves the answer into a contact property (which must exist, have a type compatible with the block and be used by one block only). The email, first_name and last_name blocks already save on the contact.
Conditions and logic between pages
visible_if (on a block) hides the block while the condition is false, and logic (on a page) sends the person to another page after answering. A condition is {"field", "operator", "value"}, where field is the id of a block that comes before and operator is equals, not_equals, contains, not_contains, is_empty, is_not_empty, greater_than or less_than. Each logic rule is {"when": condition, "go_to": "page id"}; the first one that matches applies, and go_to points to a later page or to "submit" (up to 20 rules per page). On submission, only the answers of blocks that are visible on the path the person took are validated and kept.
{
"id": "p2",
"blocks": [
{ "id": "interest", "type": "single_choice", "label": "...", "options": [{ "value": "news", "label": "..." }, { "value": "offers", "label": "..." }] },
{ "id": "budget", "type": "number", "label": "...", "visible_if": { "field": "interest", "operator": "equals", "value": "offers" } }
],
"logic": [{ "when": { "field": "interest", "operator": "equals", "value": "news" }, "go_to": "submit" }]
}
Structure errors come as 422 validation_error with message (the first problem) and errors: a list of {"page_id", "block_id", "message"}, in English. Media (media) is {"type": "image|illustration|video", "url", "illustration", "alt", "layout": "above|split_left|split_right|background"}: images need an https URL (upload the file with POST /assets) and alt; illustrations are the ones from GET /forms/illustrations; videos are YouTube or Vimeo links.
#Settings and closing
The settings object controls the form's flow and when it closes. Everything is optional; what you do not send takes the default. When updating, the object you send replaces the previous one.
| Field | Default | What it does |
|---|---|---|
display | pages | pages shows the blocks of a page together; one_by_one shows one block at a time. |
show_progress | true | Progress bar. |
next_label, back_label | null | Text of the next and back buttons (up to 60 characters). |
welcome | off | Welcome screen: enabled, title, description (up to 2,000), button_label and media. |
keyboard_hints | true | Keyboard hints on the hosted page. |
transition | slide | slide, fade or none. |
validate_email | false | Checks the email address before accepting the submission: refuses disposable addresses and domains that cannot receive email, and points out a likely typo (gmial.com) in suggestion once (the same address sent again within 15 minutes goes through). It uses no verification credits. |
closes_at | null | Date and time (ISO 8601) when the form stops accepting submissions. |
max_submissions | null | Number of submissions (1 to 10,000,000) after which the form closes. |
closed_message | null | Text (up to 2,000 characters) shown to anyone who arrives after the form closed. |
prevent_duplicates | false | Refuses (422) an email address that already answered the form; failed submissions do not count. |
show_branding | null | false hides the "Sent with Postal" badge on the public page. Only plans that do not include the badge allow it (403 plan_limit_reached). |
language | auto | Language of what Postal writes for the people who fill in the form: auto, pt-BR or en (null means auto). With auto, it follows the Accept-Language of the person's browser (pt-BR or en; without the header or with another language, pt-BR). It applies to the hosted page, the messages (validation, duplicates, closed or unavailable form), the default success and button texts and the double opt-in confirmation email. The texts you wrote (titles, questions, success message, closed_message) are never translated. See Public submission. |
The form returns closed_reason: null while open, manual (status: "paused"), scheduled (closes_at has passed) or limit (max_submissions was reached). A closed form answers JSON submissions with 410 and the name form_closed (and shows the message on the hosted page); a form paused by hand without a closed_message, or paused by the plan, answers 404 not_found.
In notifications, the form emails an alert for each submission: enabled, recipients (up to 10 addresses), include_answers (default true), reply_to_respondent (default true), subject (up to 200 characters) and frequency (instant or daily, a digest at 8:00 Brasília time). An address that does not belong to a team member gets an invitation and only starts receiving after confirming (status: pending or confirmed); you only send addresses, and Postal controls the status. These alerts do not count against the sending quota.
#List forms
GET/forms
Each form in the list also has the numbers for the last 30 days: views_30d, submissions_30d, conversion_30d (submissions over views, from 0 to 1) and last_submission_at. paused_by_plan is true when the plan no longer covers the form: it is still listed and can be read or deleted, but cannot be changed (403 paused_by_plan).
Query parameters
limitintegerFrom 1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.searchstringSearches the name.
cURL
curl https://api.postal.so/forms \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms', {
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/forms",
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('forms', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "form",
"id": "019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d",
"paused_by_plan": false,
"name": "Newsletter",
"slug": "newsletter-k3x9p2qa",
"status": "active",
"public_url": "https://forms.postal.so/newsletter-k3x9p2qa",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"topic_ids": ["019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b"],
"pages": [
{
"id": "p1",
"title": null,
"description": null,
"media": null,
"logic": [],
"blocks": [
{
"id": "email",
"type": "email",
"label": "Email",
"description": null,
"placeholder": "[email protected]",
"required": true,
"options": null,
"min": null,
"max": null,
"property": null,
"visible_if": null,
"media": null,
"columns": null,
"randomize": false,
"other": false
},
{
"id": "first_name",
"type": "first_name",
"label": "First name",
"description": null,
"placeholder": null,
"required": false,
"options": null,
"min": null,
"max": null,
"property": null,
"visible_if": null,
"media": null,
"columns": null,
"randomize": false,
"other": false
}
]
},
{
"id": "p2",
"title": "About you",
"description": null,
"media": null,
"logic": [],
"blocks": [
{
"id": "interest",
"type": "single_choice",
"label": "What would you like to receive?",
"description": null,
"placeholder": null,
"required": true,
"options": [
{
"value": "news",
"label": "Product news",
"image_url": null,
"illustration": null
},
{
"value": "offers",
"label": "Offers",
"image_url": null,
"illustration": null
}
],
"min": null,
"max": null,
"property": null,
"visible_if": null,
"media": null,
"columns": null,
"randomize": false,
"other": true
}
]
}
],
"settings": {
"display": "pages",
"show_progress": true,
"next_label": null,
"back_label": null,
"welcome": {
"enabled": false,
"title": null,
"description": null,
"button_label": null,
"media": null
},
"keyboard_hints": true,
"transition": "slide",
"validate_email": false,
"closes_at": null,
"max_submissions": null,
"closed_message": null,
"prevent_duplicates": false,
"show_branding": null,
"language": "auto"
},
"fields": [
{
"key": "email",
"label": "Email",
"placeholder": "[email protected]",
"required": true,
"type": "email"
},
{
"key": "first_name",
"label": "First name",
"placeholder": null,
"required": false,
"type": "text"
}
],
"double_opt_in": true,
"content": {
"title": "Get our latest news",
"description": "Launches and offers, once a week.",
"logo_url": null
},
"success": {
"logo_url": null,
"title": "Subscription confirmed!",
"message": "Thank you for subscribing. You will hear from us soon by email.",
"button_label": null,
"button_url": null,
"media": null
},
"redirect_url": null,
"style": {
"layout": "stacked",
"preset": null,
"font": "system",
"accent_color": "#4f46e5",
"text_color": "#18181b",
"background_color": "#ffffff",
"answer_color": null,
"button_text": "Sign up",
"button_color": "#4f46e5",
"button_text_color": "#ffffff",
"rounded": true,
"radius": "md",
"background": null,
"logo_url": null
},
"allowed_domains": ["acmestore.com"],
"notifications": {
"enabled": true,
"recipients": [
{
"email": "[email protected]",
"status": "confirmed",
"confirmed_at": "2026-10-21T12:30:00.000000Z",
"invited_at": "2026-10-21T12:25:00.000000Z"
}
],
"include_answers": true,
"reply_to_respondent": true,
"subject": null,
"frequency": "instant"
},
"submissions_count": 412,
"shows_postal_branding": false,
"closed_reason": null,
"created_at": "2026-10-21T12:00:00.000000Z",
"updated_at": "2026-10-21T12:00:00.000000Z",
"views_30d": 1820,
"submissions_30d": 96,
"conversion_30d": 0.0527,
"last_submission_at": "2026-10-24T15:00:00Z"
}
]
}
#Create a form
POST/forms
Returns the complete form, with the public_url and the default values applied.
Request body
namestringrequiredInternal name, up to 255 characters.segment_idstringrequiredStatic segment that receives the contacts.slugstringPublic address, 3 to 64 characters (lowercase letters, numbers and hyphens), unique across Postal. Default: generated from the name, such asnewsletter-k3x9p2qa.statusstringactive(default) orpaused(does not accept submissions).topic_idsstring[]Up to 20 topics the contact is subscribed to (opt_in).pagesobject[]The form's pages and blocks. A new form starts with one page that has only the email block (label and placeholder in the language of the request). Whenpagesandfieldsare sent together,pageswins.settingsobjectSettings for the flow and closing.fieldsobject[]From 1 to 20 fields, in display order. Theemailfield is required. Default: just the email address. This is the simple format: it becomes a single page, and for questions, logic and media usepages.keystringrequiredemail,first_name,last_nameor the key of a property. The field's type follows the property's type.labelstringLabel, up to 255 characters.placeholderstringPlaceholder text, up to 255 characters.requiredbooleanRequired. The email field is always required.
double_opt_inbooleanAsks for confirmation by email before adding the contact.contentobjectTexts of the hosted page:title,description(up to 2,000 characters) andlogo_url.successobjectSuccess screen:logo_url,title,message,button_label,button_urlandmedia. Withouttitleandmessage, the page uses a default text in the form language (in API responses, the default appears in the language of the request).redirect_urlstring | nullURL to send the person to after submitting (or after confirming, with double opt-in), instead of the success screen.styleobjectAppearance:layout(stackedorinline),font(system,serif,mono,rounded,inter,plus_jakarta_sans,dm_sans,space_grotesk,fraunces,playfair_display,ibm_plex_mono), colors in hexadecimal (accent_color,text_color,background_color,button_color,button_text_color,answer_color),button_text,radius(none,sm,md,lg,full;roundedis the old format),preset(a theme fromGET /forms/themes),logo_url(https) andbackground({"type": "color|gradient|image", …}, withcolor,gradient{from, to, angle}orimage_url, plusoverlayfrom 0 to 0.85 andoverlay_color).allowed_domainsstring[]Up to 50 domains allowed to submit the form from a browser, such asacmestore.com(subdomains included). Empty: any site.notificationsobjectEmail alerts for each submission, see Settings and closing.
Errors
- 422
validation_errorInvalid field, slug already in use, pages or blocks that break the rules (seeerrors), a form without theemailblock, or a field that is neither a contact field nor a property. - 404
not_foundThe segment or a topic does not exist. - 403
plan_limit_reachedThe plan does not allow more forms, or the plan shows the Postal badge and the form asked forsettings.show_branding: false.
cURL
curl -X POST https://api.postal.so/forms \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Newsletter",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"topic_ids": ["019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b"],
"double_opt_in": true,
"pages": [
{
"id": "p1",
"blocks": [
{
"id": "email",
"type": "email",
"label": "Email",
"placeholder": "[email protected]"
},
{
"id": "first_name",
"type": "first_name",
"label": "First name"
}
]
},
{
"id": "p2",
"title": "About you",
"blocks": [
{
"id": "interest",
"type": "single_choice",
"label": "What would you like to receive?",
"required": true,
"other": true,
"options": [
{
"value": "news",
"label": "Product news"
},
{
"value": "offers",
"label": "Offers"
}
]
}
]
}
],
"settings": {
"max_submissions": 5000,
"prevent_duplicates": true
},
"content": {
"title": "Get our latest news"
},
"allowed_domains": ["acmestore.com"]
}'
Node.js
const response = await fetch('https://api.postal.so/forms', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Newsletter',
segment_id: '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
topic_ids: ['019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b'],
double_opt_in: true,
pages: [
{
id: 'p1',
blocks: [
{
id: 'email',
type: 'email',
label: 'Email',
placeholder: '[email protected]',
},
{
id: 'first_name',
type: 'first_name',
label: 'First name',
},
],
},
{
id: 'p2',
title: 'About you',
blocks: [
{
id: 'interest',
type: 'single_choice',
label: 'What would you like to receive?',
required: true,
other: true,
options: [
{
value: 'news',
label: 'Product news',
},
{
value: 'offers',
label: 'Offers',
},
],
},
],
},
],
settings: {
max_submissions: 5000,
prevent_duplicates: true,
},
content: {
title: 'Get our latest news',
},
allowed_domains: ['acmestore.com'],
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/forms",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"name": "Newsletter",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"topic_ids": ["019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b"],
"double_opt_in": True,
"pages": [
{
"id": "p1",
"blocks": [
{
"id": "email",
"type": "email",
"label": "Email",
"placeholder": "[email protected]",
},
{
"id": "first_name",
"type": "first_name",
"label": "First name",
},
],
},
{
"id": "p2",
"title": "About you",
"blocks": [
{
"id": "interest",
"type": "single_choice",
"label": "What would you like to receive?",
"required": True,
"other": True,
"options": [
{
"value": "news",
"label": "Product news",
},
{
"value": "offers",
"label": "Offers",
},
],
},
],
},
],
"settings": {
"max_submissions": 5000,
"prevent_duplicates": True,
},
"content": {
"title": "Get our latest news",
},
"allowed_domains": ["acmestore.com"],
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('forms', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'name' => 'Newsletter',
'segment_id' => '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
'topic_ids' => ['019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b'],
'double_opt_in' => true,
'pages' => [
[
'id' => 'p1',
'blocks' => [
[
'id' => 'email',
'type' => 'email',
'label' => 'Email',
'placeholder' => '[email protected]',
],
[
'id' => 'first_name',
'type' => 'first_name',
'label' => 'First name',
],
],
],
[
'id' => 'p2',
'title' => 'About you',
'blocks' => [
[
'id' => 'interest',
'type' => 'single_choice',
'label' => 'What would you like to receive?',
'required' => true,
'other' => true,
'options' => [
[
'value' => 'news',
'label' => 'Product news',
],
[
'value' => 'offers',
'label' => 'Offers',
],
],
],
],
],
],
'settings' => [
'max_submissions' => 5000,
'prevent_duplicates' => true,
],
'content' => [
'title' => 'Get our latest news',
],
'allowed_domains' => ['acmestore.com'],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "form",
"id": "019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d",
"paused_by_plan": false,
"name": "Newsletter",
"slug": "newsletter-k3x9p2qa",
"status": "active",
"public_url": "https://forms.postal.so/newsletter-k3x9p2qa",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"topic_ids": ["019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b"],
"pages": [
{
"id": "p1",
"title": null,
"description": null,
"media": null,
"logic": [],
"blocks": [
{
"id": "email",
"type": "email",
"label": "Email",
"description": null,
"placeholder": "[email protected]",
"required": true,
"options": null,
"min": null,
"max": null,
"property": null,
"visible_if": null,
"media": null,
"columns": null,
"randomize": false,
"other": false
},
{
"id": "first_name",
"type": "first_name",
"label": "First name",
"description": null,
"placeholder": null,
"required": false,
"options": null,
"min": null,
"max": null,
"property": null,
"visible_if": null,
"media": null,
"columns": null,
"randomize": false,
"other": false
}
]
},
{
"id": "p2",
"title": "About you",
"description": null,
"media": null,
"logic": [],
"blocks": [
{
"id": "interest",
"type": "single_choice",
"label": "What would you like to receive?",
"description": null,
"placeholder": null,
"required": true,
"options": [
{
"value": "news",
"label": "Product news",
"image_url": null,
"illustration": null
},
{
"value": "offers",
"label": "Offers",
"image_url": null,
"illustration": null
}
],
"min": null,
"max": null,
"property": null,
"visible_if": null,
"media": null,
"columns": null,
"randomize": false,
"other": true
}
]
}
],
"settings": {
"display": "pages",
"show_progress": true,
"next_label": null,
"back_label": null,
"welcome": {
"enabled": false,
"title": null,
"description": null,
"button_label": null,
"media": null
},
"keyboard_hints": true,
"transition": "slide",
"validate_email": false,
"closes_at": null,
"max_submissions": null,
"closed_message": null,
"prevent_duplicates": false,
"show_branding": null,
"language": "auto"
},
"fields": [
{
"key": "email",
"label": "Email",
"placeholder": "[email protected]",
"required": true,
"type": "email"
},
{
"key": "first_name",
"label": "First name",
"placeholder": null,
"required": false,
"type": "text"
}
],
"double_opt_in": true,
"content": {
"title": "Get our latest news",
"description": "Launches and offers, once a week.",
"logo_url": null
},
"success": {
"logo_url": null,
"title": "Subscription confirmed!",
"message": "Thank you for subscribing. You will hear from us soon by email.",
"button_label": null,
"button_url": null,
"media": null
},
"redirect_url": null,
"style": {
"layout": "stacked",
"preset": null,
"font": "system",
"accent_color": "#4f46e5",
"text_color": "#18181b",
"background_color": "#ffffff",
"answer_color": null,
"button_text": "Sign up",
"button_color": "#4f46e5",
"button_text_color": "#ffffff",
"rounded": true,
"radius": "md",
"background": null,
"logo_url": null
},
"allowed_domains": ["acmestore.com"],
"notifications": {
"enabled": true,
"recipients": [
{
"email": "[email protected]",
"status": "confirmed",
"confirmed_at": "2026-10-21T12:30:00.000000Z",
"invited_at": "2026-10-21T12:25:00.000000Z"
}
],
"include_answers": true,
"reply_to_respondent": true,
"subject": null,
"frequency": "instant"
},
"submissions_count": 0,
"shows_postal_branding": false,
"closed_reason": null,
"created_at": "2026-10-21T12:00:00.000000Z",
"updated_at": "2026-10-21T12:00:00.000000Z"
}
#Get a form
GET/forms/{id}
Path parameters
idstringrequiredForm ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d', {
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/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d",
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('forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "form",
"id": "019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d",
"paused_by_plan": false,
"name": "Newsletter",
"slug": "newsletter-k3x9p2qa",
"status": "active",
"public_url": "https://forms.postal.so/newsletter-k3x9p2qa",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"topic_ids": ["019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b"],
"pages": [
{
"id": "p1",
"title": null,
"description": null,
"media": null,
"logic": [],
"blocks": [
{
"id": "email",
"type": "email",
"label": "Email",
"description": null,
"placeholder": "[email protected]",
"required": true,
"options": null,
"min": null,
"max": null,
"property": null,
"visible_if": null,
"media": null,
"columns": null,
"randomize": false,
"other": false
},
{
"id": "first_name",
"type": "first_name",
"label": "First name",
"description": null,
"placeholder": null,
"required": false,
"options": null,
"min": null,
"max": null,
"property": null,
"visible_if": null,
"media": null,
"columns": null,
"randomize": false,
"other": false
}
]
},
{
"id": "p2",
"title": "About you",
"description": null,
"media": null,
"logic": [],
"blocks": [
{
"id": "interest",
"type": "single_choice",
"label": "What would you like to receive?",
"description": null,
"placeholder": null,
"required": true,
"options": [
{
"value": "news",
"label": "Product news",
"image_url": null,
"illustration": null
},
{
"value": "offers",
"label": "Offers",
"image_url": null,
"illustration": null
}
],
"min": null,
"max": null,
"property": null,
"visible_if": null,
"media": null,
"columns": null,
"randomize": false,
"other": true
}
]
}
],
"settings": {
"display": "pages",
"show_progress": true,
"next_label": null,
"back_label": null,
"welcome": {
"enabled": false,
"title": null,
"description": null,
"button_label": null,
"media": null
},
"keyboard_hints": true,
"transition": "slide",
"validate_email": false,
"closes_at": null,
"max_submissions": null,
"closed_message": null,
"prevent_duplicates": false,
"show_branding": null,
"language": "auto"
},
"fields": [
{
"key": "email",
"label": "Email",
"placeholder": "[email protected]",
"required": true,
"type": "email"
},
{
"key": "first_name",
"label": "First name",
"placeholder": null,
"required": false,
"type": "text"
}
],
"double_opt_in": true,
"content": {
"title": "Get our latest news",
"description": "Launches and offers, once a week.",
"logo_url": null
},
"success": {
"logo_url": null,
"title": "Subscription confirmed!",
"message": "Thank you for subscribing. You will hear from us soon by email.",
"button_label": null,
"button_url": null,
"media": null
},
"redirect_url": null,
"style": {
"layout": "stacked",
"preset": null,
"font": "system",
"accent_color": "#4f46e5",
"text_color": "#18181b",
"background_color": "#ffffff",
"answer_color": null,
"button_text": "Sign up",
"button_color": "#4f46e5",
"button_text_color": "#ffffff",
"rounded": true,
"radius": "md",
"background": null,
"logo_url": null
},
"allowed_domains": ["acmestore.com"],
"notifications": {
"enabled": true,
"recipients": [
{
"email": "[email protected]",
"status": "confirmed",
"confirmed_at": "2026-10-21T12:30:00.000000Z",
"invited_at": "2026-10-21T12:25:00.000000Z"
}
],
"include_answers": true,
"reply_to_respondent": true,
"subject": null,
"frequency": "instant"
},
"submissions_count": 412,
"shows_postal_branding": false,
"closed_reason": null,
"created_at": "2026-10-21T12:00:00.000000Z",
"updated_at": "2026-10-21T12:00:00.000000Z"
}
#Update a form
PATCH/forms/{id}
All the creation fields are accepted and optional. In pages, content, success, style and settings, the object you send replaces the previous one. Returns the updated form.
Path parameters
idstringrequiredForm ID.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 403
paused_by_planThe form is paused because it is over the plan's limit. - 403
plan_limit_reachedsettings.show_branding: falseon a plan that shows the Postal badge. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X PATCH https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "paused"
}'
Node.js
const response = await fetch('https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
status: 'paused',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.patch(
"https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"status": "paused",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->patch('forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'status' => 'paused',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "form",
"id": "019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d",
"paused_by_plan": false,
"name": "Newsletter",
"slug": "newsletter-k3x9p2qa",
"status": "paused",
"public_url": "https://forms.postal.so/newsletter-k3x9p2qa",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"topic_ids": ["019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b"],
"pages": [
{
"id": "p1",
"title": null,
"description": null,
"media": null,
"logic": [],
"blocks": [
{
"id": "email",
"type": "email",
"label": "Email",
"description": null,
"placeholder": "[email protected]",
"required": true,
"options": null,
"min": null,
"max": null,
"property": null,
"visible_if": null,
"media": null,
"columns": null,
"randomize": false,
"other": false
},
{
"id": "first_name",
"type": "first_name",
"label": "First name",
"description": null,
"placeholder": null,
"required": false,
"options": null,
"min": null,
"max": null,
"property": null,
"visible_if": null,
"media": null,
"columns": null,
"randomize": false,
"other": false
}
]
},
{
"id": "p2",
"title": "About you",
"description": null,
"media": null,
"logic": [],
"blocks": [
{
"id": "interest",
"type": "single_choice",
"label": "What would you like to receive?",
"description": null,
"placeholder": null,
"required": true,
"options": [
{
"value": "news",
"label": "Product news",
"image_url": null,
"illustration": null
},
{
"value": "offers",
"label": "Offers",
"image_url": null,
"illustration": null
}
],
"min": null,
"max": null,
"property": null,
"visible_if": null,
"media": null,
"columns": null,
"randomize": false,
"other": true
}
]
}
],
"settings": {
"display": "pages",
"show_progress": true,
"next_label": null,
"back_label": null,
"welcome": {
"enabled": false,
"title": null,
"description": null,
"button_label": null,
"media": null
},
"keyboard_hints": true,
"transition": "slide",
"validate_email": false,
"closes_at": null,
"max_submissions": null,
"closed_message": null,
"prevent_duplicates": false,
"show_branding": null,
"language": "auto"
},
"fields": [
{
"key": "email",
"label": "Email",
"placeholder": "[email protected]",
"required": true,
"type": "email"
},
{
"key": "first_name",
"label": "First name",
"placeholder": null,
"required": false,
"type": "text"
}
],
"double_opt_in": true,
"content": {
"title": "Get our latest news",
"description": "Launches and offers, once a week.",
"logo_url": null
},
"success": {
"logo_url": null,
"title": "Subscription confirmed!",
"message": "Thank you for subscribing. You will hear from us soon by email.",
"button_label": null,
"button_url": null,
"media": null
},
"redirect_url": null,
"style": {
"layout": "stacked",
"preset": null,
"font": "system",
"accent_color": "#4f46e5",
"text_color": "#18181b",
"background_color": "#ffffff",
"answer_color": null,
"button_text": "Sign up",
"button_color": "#4f46e5",
"button_text_color": "#ffffff",
"rounded": true,
"radius": "md",
"background": null,
"logo_url": null
},
"allowed_domains": ["acmestore.com"],
"notifications": {
"enabled": true,
"recipients": [
{
"email": "[email protected]",
"status": "confirmed",
"confirmed_at": "2026-10-21T12:30:00.000000Z",
"invited_at": "2026-10-21T12:25:00.000000Z"
}
],
"include_answers": true,
"reply_to_respondent": true,
"subject": null,
"frequency": "instant"
},
"submissions_count": 412,
"shows_postal_branding": false,
"closed_reason": "manual",
"created_at": "2026-10-21T12:00:00.000000Z",
"updated_at": "2026-10-21T12:00:00.000000Z"
}
#Delete a form
DELETE/forms/{id}
The public address stops working. Contacts that already subscribed stay in the audience.
Path parameters
idstringrequiredForm ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X DELETE https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d",
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->delete('forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "form",
"id": "019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d",
"deleted": true
}
#Theme presets
GET/forms/themes
The editor's ready-made themes. Each one carries a complete style to use in style when creating or updating a form (preset: postal, minimal, midnight, sunset, forest, ocean, paper or neon).
cURL
curl https://api.postal.so/forms/themes \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms/themes', {
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/forms/themes",
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('forms/themes', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"data": [
{
"key": "postal",
"label": "Postal",
"style": {
"preset": "postal",
"font": "plus_jakarta_sans",
"radius": "md",
"background_color": "#f5f2ff",
"text_color": "#1e1846",
"accent_color": "#5b3df5",
"answer_color": "#ffffff"
}
}
]
}
#Built-in illustrations
GET/forms/illustrations
The library of illustrations to use in media ("type": "illustration"), in choice options and in the form's background.
cURL
curl https://api.postal.so/forms/illustrations \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms/illustrations', {
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/forms/illustrations",
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('forms/illustrations', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"data": [
{
"key": "rocket",
"label": "Rocket",
"url": "https://media.postal.so/form-illustrations/rocket.svg"
}
]
}
#List a form's submissions
GET/forms/{id}/submissions
Each submission received, newest first. status: pending (queued), pending_confirmation (confirmation email sent), subscribed (contact in the audience) or failed (for example, the plan's contact limit was reached or the email address is on the suppression list). answers has the answers by each block's id, and consent the date and IP of the consent, when there was one.
Path parameters
idstringrequiredForm ID.
Query parameters
limitintegerFrom 1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.searchstringSearches the email address, the name and the text of the answers.statusstringOne or more statuses, comma-separated:pending,pending_confirmation,subscribed,failed.start_datestringStart of the period, inclusive: a date (2026-10-01) or an ISO 8601 date-time.end_datestringEnd of the period, inclusive. A date alone covers the whole day (in UTC).422if it is beforestart_date.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl 'https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/submissions?status=subscribed&start_date=2026-10-01&end_date=2026-10-24' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/submissions?status=subscribed&start_date=2026-10-01&end_date=2026-10-24', {
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/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/submissions",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"status": "subscribed",
"start_date": "2026-10-01",
"end_date": "2026-10-24",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/submissions', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'status' => 'subscribed',
'start_date' => '2026-10-01',
'end_date' => '2026-10-24',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": true,
"data": [
{
"object": "form_submission",
"id": "019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e",
"form_id": "019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d",
"contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"email": "[email protected]",
"answers": {
"email": "[email protected]",
"first_name": "Jane",
"interest": "news"
},
"consent": null,
"data": {
"email": "[email protected]",
"first_name": "Jane",
"properties": {}
},
"status": "subscribed",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)",
"referrer": "https://acmestore.com/blog",
"created_at": "2026-10-24T15:00:00.000000Z",
"confirmed_at": "2026-10-24T15:03:12.000000Z"
}
]
}
#Get a submission
GET/forms/{id}/submissions/{submission_id}
Path parameters
idstringrequiredForm ID.submission_idstringrequiredSubmission ID.
Errors
- 404
not_foundThe form or the submission does not exist.
cURL
curl https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/submissions/019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/submissions/019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e', {
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/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/submissions/019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e",
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('forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/submissions/019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "form_submission",
"id": "019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e",
"form_id": "019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d",
"contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"email": "[email protected]",
"answers": {
"email": "[email protected]",
"first_name": "Jane",
"interest": "news"
},
"consent": null,
"data": {
"email": "[email protected]",
"first_name": "Jane",
"properties": {}
},
"status": "subscribed",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)",
"referrer": "https://acmestore.com/blog",
"created_at": "2026-10-24T15:00:00.000000Z",
"confirmed_at": "2026-10-24T15:03:12.000000Z"
}
#Delete a submission
DELETE/forms/{id}/submissions/{submission_id}
Deletes the submission record and takes one off submissions_count. The contact it created stays in the audience; to remove it, delete the contact.
Path parameters
idstringrequiredForm ID.submission_idstringrequiredSubmission ID.
Errors
- 404
not_foundThe form or the submission does not exist.
cURL
curl -X DELETE https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/submissions/019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/submissions/019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/submissions/019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e",
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->delete('forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/submissions/019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "form_submission",
"id": "019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e",
"deleted": true
}
#Export a form's submissions
GET/forms/{id}/submissions/export
urns a CSV file (text/csv, UTF-8 with a BOM), right away and without pagination. Requires a plan that includes exports (403 plan_limit_reached if it does n One column per question of the form. Accepts the same filters as the list (search, status, start_date and end_date). An answer typed in the “Other” option comes out as Other: text (or Outro: texto, depending on the request's Accept-Language).
Path parameters
idstringrequiredForm ID.
Query parameters
searchstringSearches the email address, the name and the text of the answers.statusstringOne or more statuses, comma-separated:pending,pending_confirmation,subscribed,failed.start_datestringStart of the period, inclusive: a date (2026-10-01) or an ISO 8601 date-time.end_datestringEnd of the period, inclusive. A date alone covers the whole day (in UTC).422if it is beforestart_date.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 403
plan_limit_reachedThe plan does not include exports. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl 'https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/submissions/export?start_date=2026-10-01' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/submissions/export?start_date=2026-10-01', {
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/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/submissions/export",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"start_date": "2026-10-01",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/submissions/export', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'start_date' => '2026-10-01',
],
]);
$data = json_decode($response->getBody(), true);
id,created_at,status,Email,First name,What would you like to receive?,consent_given_at
019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e,2026-10-24T15:00:00+00:00,subscribed,[email protected],Jane,Product news,
#List submissions across all forms
GET/forms/submissions
The submissions of all the team's forms in a single list, newest first. Each row has the form, who submitted, the first answers (answers_preview) and where the visitor came from (utm_source, device, country, when the visit was measured).
Query parameters
limitintegerFrom 1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.form_idstringOnly the submissions of this form.searchstringSearches the email address, the name and the text of the answers.statusstringOne or more statuses, comma-separated:pending,pending_confirmation,subscribed,failed.start_datestringStart of the period, inclusive: a date (2026-10-01) or an ISO 8601 date-time.end_datestringEnd of the period, inclusive. A date alone covers the whole day (in UTC).422if it is beforestart_date.
Errors
- 422
validation_errorA field is invalid. The message says which one.
cURL
curl 'https://api.postal.so/forms/submissions?status=subscribed&start_date=2026-10-01' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms/submissions?status=subscribed&start_date=2026-10-01', {
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/forms/submissions",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"status": "subscribed",
"start_date": "2026-10-01",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('forms/submissions', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'status' => 'subscribed',
'start_date' => '2026-10-01',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "form_submission_row",
"id": "019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e",
"form_id": "019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d",
"form_name": "Newsletter",
"email": "[email protected]",
"name": "Jane Doe",
"status": "subscribed",
"answers_preview": [
{
"id": "first_name",
"question": "First name",
"value": "Jane"
},
{
"id": "interest",
"question": "What would you like to receive?",
"value": "Product news"
}
],
"contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"utm_source": "newsletter",
"device": "mobile",
"country": "BR",
"created_at": "2026-10-24T15:00:00.000000Z"
}
]
}
#Export submissions across all forms
GET/forms/submissions/export
urns a CSV file (text/csv, UTF-8 with a BOM), right away and without pagination. Requires a plan that includes exports (403 plan_limit_reached if it does n One row per submission, with the answers in answers as "question: answer" pairs and the columns id, created_at, form_id, form_name, status, email, name, contact_id, utm_source, utm_medium, utm_campaign, source, device and country. Accepts the same filters as the list. An answer typed in the “Other” option comes out as Other: text (or Outro: texto, depending on the request's Accept-Language).
Query parameters
form_idstringOnly the submissions of this form.searchstringSearches the email address, the name and the text of the answers.statusstringOne or more statuses, comma-separated:pending,pending_confirmation,subscribed,failed.start_datestringStart of the period, inclusive: a date (2026-10-01) or an ISO 8601 date-time.end_datestringEnd of the period, inclusive. A date alone covers the whole day (in UTC).422if it is beforestart_date.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 403
plan_limit_reachedThe plan does not include exports.
cURL
curl 'https://api.postal.so/forms/submissions/export?start_date=2026-10-01' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms/submissions/export?start_date=2026-10-01', {
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/forms/submissions/export",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"start_date": "2026-10-01",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('forms/submissions/export', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'start_date' => '2026-10-01',
],
]);
$data = json_decode($response->getBody(), true);
id,created_at,form_id,form_name,status,email,name,contact_id,answers,utm_source,utm_medium,utm_campaign,source,device,country
019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e,2026-10-24T15:00:00+00:00,019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d,Newsletter,subscribed,[email protected],Jane Doe,019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f,"First name: Jane; What would you like to receive?: Product news",newsletter,email,,,mobile,BR
#Form statistics
The statistics count visits to the hosted page and to embedded forms (Postal measures visits through a signal the page itself sends; submissions from other systems show up in all_submissions, but not in visits), how far each person got and the submissions. They require a plan with form analytics (403 plan_limit_reached if it has none). All of them accept the period in from and to (dates or ISO 8601 date-times; a date alone covers the whole day; default: the last 30 days; up to 400 days), tz (an IANA time zone, such as America/Sao_Paulo; default UTC) and interval (day or hour). The end never goes past now; 422 if from is after to, if the time zone does not exist or if the period produces more than 2,000 intervals. The heavy numbers are recalculated at most once a minute.
#Overview of all forms
GET/forms/overview
The statistics of all the team's forms added together, over the same period and with the same parameters as a single form's analytics. Each number in totals comes as {"value", "previous"}, with the previous period of the same length; series has the intervals; top_forms, the 10 forms with the most visits (with a sparkline of submissions per day); breakdowns, the top 8 sources, UTMs, devices, browsers, countries and placements; hours_heatmap; recent_submissions; and the list_quality of all forms.
list_quality measures the quality of the audience the period's submissions brought in: submissions received and distinct contacts they turned into; double_opt_in (confirmed and pending); failed (submissions that did not become a contact) and suppressed_rejections (those refused because the address is on the suppression list); unsubscribed; suppressed (contacts whose address later joined the suppression list, by origin: bounce, complaint, manual…); hard_bounce_rate (permanent bounces over contacts); and verification (valid, invalid, risky and unverified, from the email verification).
Query parameters
fromstringStart of the period. Default: 30 days ago.tostringEnd of the period. Default: now.tzstringIANA time zone. Default:UTC.intervalstringday(default) orhour.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 403
plan_limit_reachedThe plan does not include form analytics.
cURL
curl 'https://api.postal.so/forms/overview?from=2026-10-01&to=2026-10-24&tz=America/Sao_Paulo' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms/overview?from=2026-10-01&to=2026-10-24&tz=America/Sao_Paulo', {
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/forms/overview",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"from": "2026-10-01",
"to": "2026-10-24",
"tz": "America/Sao_Paulo",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('forms/overview', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'from' => '2026-10-01',
'to' => '2026-10-24',
'tz' => 'America/Sao_Paulo',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "forms_overview",
"from": "2026-10-01T00:00:00-03:00",
"to": "2026-10-24T23:59:59-03:00",
"interval": "day",
"timezone": "America/Sao_Paulo",
"previous_period": {
"from": "2026-09-07T00:00:00-03:00",
"to": "2026-09-30T23:59:59-03:00"
},
"totals": {
"forms": {
"value": 6,
"previous": 5
},
"active_forms": {
"value": 5,
"previous": null
},
"views": {
"value": 1820,
"previous": 1544
},
"submissions": {
"value": 412,
"previous": 377
},
"conversion_rate": {
"value": 0.2264,
"previous": 0.2442
}
},
"series": [
{
"date": "2026-10-24T00:00:00-03:00",
"views": 71,
"starts": 25,
"submissions": 17
}
],
"top_forms": [
{
"id": "019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d",
"name": "Newsletter",
"status": "active",
"views": 1250,
"submissions": 301,
"conversion_rate": 0.2408,
"sparkline": [9, 12, 10, 15, 17]
}
],
"breakdowns": {
"sources": [
{
"value": "acmestore.com",
"views": 640,
"starts": 280,
"submissions": 190,
"conversion_rate": 0.2969
}
],
"devices": [
{
"value": "mobile",
"views": 1100,
"starts": 380,
"submissions": 240,
"conversion_rate": 0.2182
}
]
},
"hours_heatmap": {
"max": 14,
"cells": [
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
]
},
"recent_submissions": [
{
"id": "019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e",
"form_id": "019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d",
"form_name": "Newsletter",
"email": "[email protected]",
"name": "Jane Doe",
"created_at": "2026-10-24T15:00:00-03:00"
}
],
"list_quality": {
"submissions": 412,
"contacts": 371,
"double_opt_in": {
"confirmed": 340,
"pending": 21
},
"failed": 9,
"suppressed_rejections": 3,
"unsubscribed": 4,
"suppressed": {
"total": 6,
"bounce": 2,
"complaint": 1,
"manual": 3
},
"hard_bounce_rate": 0.0054,
"verification": {
"valid": 280,
"invalid": 12,
"risky": 18,
"unverified": 61
}
}
}
#A form's analytics
GET/forms/{id}/analytics
A form's visits, conversion and answers over the period. Main fields: totals (views, unique_visitors, starts, submissions, all_submissions, abandoned, bounced, the rates start_rate, completion_rate and conversion_rate from 0 to 1, and the average and median time in seconds); previous (the same totals for the previous period of the same length); funnel (each step with how many visits reached it, where people gave up and the biggest_drop); timeseries; breakdowns (by source, UTM, device, browser, country and placement: page, iframe or inline); questions (a summary per question, with summary.kind: choice, yes_no, consent, rating, nps, number, date, text or values); heatmap (weekday by hour); recent_submitters and list_quality. sampled is true when the period has more than 20,000 submissions and the questions read only the most recent ones.
list_quality measures the quality of the audience the period's submissions brought in: submissions received and distinct contacts they turned into; double_opt_in (confirmed and pending); failed (submissions that did not become a contact) and suppressed_rejections (those refused because the address is on the suppression list); unsubscribed; suppressed (contacts whose address later joined the suppression list, by origin: bounce, complaint, manual…); hard_bounce_rate (permanent bounces over contacts); and verification (valid, invalid, risky and unverified, from the email verification).
Path parameters
idstringrequiredForm ID.
Query parameters
fromstringStart of the period. Default: 30 days ago.tostringEnd of the period. Default: now.tzstringIANA time zone. Default:UTC.intervalstringday(default) orhour.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 403
plan_limit_reachedThe plan does not include form analytics. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl 'https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/analytics?from=2026-10-01&to=2026-10-24&tz=America/Sao_Paulo' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/analytics?from=2026-10-01&to=2026-10-24&tz=America/Sao_Paulo', {
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/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/analytics",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"from": "2026-10-01",
"to": "2026-10-24",
"tz": "America/Sao_Paulo",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/analytics', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'from' => '2026-10-01',
'to' => '2026-10-24',
'tz' => 'America/Sao_Paulo',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "form_analytics",
"form_id": "019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d",
"from": "2026-10-01T00:00:00-03:00",
"to": "2026-10-24T23:59:59-03:00",
"interval": "day",
"timezone": "America/Sao_Paulo",
"totals": {
"views": 1820,
"unique_visitors": 1410,
"starts": 640,
"submissions": 412,
"all_submissions": 412,
"abandoned": 228,
"bounced": 1180,
"start_rate": 0.3516,
"completion_rate": 0.6438,
"conversion_rate": 0.2264,
"average_seconds": 48,
"median_seconds": 37
},
"previous": {
"from": "2026-09-07T00:00:00-03:00",
"to": "2026-09-30T23:59:59-03:00",
"views": 1544,
"unique_visitors": 1410,
"starts": 640,
"submissions": 377,
"all_submissions": 412,
"abandoned": 228,
"bounced": 1180,
"start_rate": 0.3516,
"completion_rate": 0.6438,
"conversion_rate": 0.2264,
"average_seconds": 48,
"median_seconds": 37
},
"anonymous": {
"views": 40,
"starts": 12,
"submissions": 9
},
"funnel": {
"views": 1820,
"starts": 640,
"submissions": 412,
"steps": [
{
"index": 0,
"id": "p1",
"kind": "page",
"type": null,
"label": "Page 1",
"page_id": "p1",
"reached": 1820,
"reached_rate": 1.0,
"drop_off": 0,
"drop_off_rate": 0.0,
"abandoned": 120,
"abandoned_rate": 0.0659
},
{
"index": 1,
"id": "p2",
"kind": "page",
"type": null,
"label": "About you",
"page_id": "p2",
"reached": 520,
"reached_rate": 0.2857,
"drop_off": 1300,
"drop_off_rate": 0.7143,
"abandoned": 108,
"abandoned_rate": 0.2077
}
],
"biggest_drop": {
"id": "p2",
"index": 1,
"label": "About you",
"abandoned": 108,
"abandoned_rate": 0.2077
}
},
"timeseries": [
{
"period": "2026-10-23T00:00:00-03:00",
"views": 64,
"starts": 22,
"submissions": 15
},
{
"period": "2026-10-24T00:00:00-03:00",
"views": 71,
"starts": 25,
"submissions": 17
}
],
"breakdowns": {
"sources": [
{
"value": "acmestore.com",
"views": 640,
"starts": 280,
"submissions": 190,
"conversion_rate": 0.2969
}
],
"utm_sources": [],
"utm_mediums": [],
"utm_campaigns": [],
"devices": [
{
"value": "mobile",
"views": 1100,
"starts": 380,
"submissions": 240,
"conversion_rate": 0.2182
}
],
"browsers": [],
"countries": [],
"placements": []
},
"questions": [
{
"id": "interest",
"type": "single_choice",
"label": "What would you like to receive?",
"page_id": "p2",
"answered": 412,
"answer_rate": 1.0,
"summary": {
"kind": "choice",
"multiple": false,
"options": [
{
"value": "news",
"label": "Product news",
"count": 301,
"rate": 0.7306
},
{
"value": "offers",
"label": "Offers",
"count": 111,
"rate": 0.2694
}
],
"other": {
"count": 0,
"rate": 0.0,
"latest": []
}
}
}
],
"heatmap": {
"max": 14,
"cells": [
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
]
},
"recent_submitters": [
{
"id": "019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e",
"email": "[email protected]",
"name": "Jane Doe",
"contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"status": "subscribed",
"created_at": "2026-10-24T15:00:00Z",
"source": "acmestore.com",
"utm_source": "newsletter",
"utm_campaign": null,
"device": "mobile",
"browser": "Safari",
"country": "BR",
"placement": "page",
"duration_seconds": 41,
"tracked": true
}
],
"list_quality": {
"submissions": 412,
"contacts": 371,
"double_opt_in": {
"confirmed": 340,
"pending": 21
},
"failed": 9,
"suppressed_rejections": 3,
"unsubscribed": 4,
"suppressed": {
"total": 6,
"bounce": 2,
"complaint": 1,
"manual": 3
},
"hard_bounce_rate": 0.0054,
"verification": {
"valid": 280,
"invalid": 12,
"risky": 18,
"unverified": 61
}
},
"sampled": false
}
#Export a form's visits
GET/forms/{id}/analytics/export
A CSV file (text/csv) with one row per visit in the period: when it viewed, started and submitted, the furthest step reached, the device and the source. No personal data, answers or submission IDs. Accepts from, to and tz.
Path parameters
idstringrequiredForm ID.
Query parameters
fromstringStart of the period. Default: 30 days ago.tostringEnd of the period. Default: now.tzstringIANA time zone. Default:UTC.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 403
plan_limit_reachedThe plan does not include form analytics. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl 'https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/analytics/export?from=2026-10-01&to=2026-10-24' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/analytics/export?from=2026-10-01&to=2026-10-24', {
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/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/analytics/export",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"from": "2026-10-01",
"to": "2026-10-24",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/analytics/export', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'from' => '2026-10-01',
'to' => '2026-10-24',
],
]);
$data = json_decode($response->getBody(), true);
visit_id,viewed_at,started_at,completed_at,submitted,duration_seconds,furthest_step_index,furthest_step,steps_reached,placement,device,browser,country,referrer_host,utm_source,utm_medium,utm_campaign
019a1e25-9102-7db3-8e2f-4a5b6c7d8e9f,2026-10-24T14:58:10+00:00,2026-10-24T14:58:19+00:00,2026-10-24T14:59:00+00:00,true,41,1,About you,2,page,mobile,Safari,BR,acmestore.com,newsletter,email,outubro
#Answers to a text question
GET/forms/{id}/analytics/questions/{question}/answers
Lists the answers to a text question (short_text, long_text, phone or hidden), newest first, without saying who answered. Choice, rating and other questions are already summarized in the analytics. Accepts the period (from, to, tz) and limit, after and before.
Path parameters
idstringrequiredForm ID.questionstringrequiredThe question block'sid.
Query parameters
limitintegerFrom 1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.fromstringStart of the period. Default: 30 days ago.tostringEnd of the period. Default: now.tzstringIANA time zone. Default:UTC.
Errors
- 422
validation_errorThe question is not a text one, or the period is invalid. - 403
plan_limit_reachedThe plan does not include form analytics. - 404
not_foundThe form or the question does not exist.
cURL
curl 'https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/analytics/questions/comment/answers?limit=20' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/analytics/questions/comment/answers?limit=20', {
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/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/analytics/questions/comment/answers",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"limit": 20,
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/analytics/questions/comment/answers', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'limit' => 20,
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "form_answer",
"submission_id": "019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e",
"question_id": "comment",
"value": "I would like to see more content about deliverability.",
"created_at": "2026-10-24T15:00:00.000000Z"
}
]
}
#Resend a notification invitation
POST/forms/{id}/notifications/recipients/{email}/resend
Sends the invitation again to a notification recipient who has not confirmed yet (including one who declined: only their own confirmation turns the alerts back on). Returns the recipient with its status and dates.
Path parameters
idstringrequiredForm ID.emailstringrequiredThe recipient's address, already listed innotifications.recipients.
Errors
- 422
validation_errorThe recipient has already confirmed. - 429
rate_limit_exceededToo many invitations to this address. Try again tomorrow. - 404
not_foundThe form or the recipient does not exist.
cURL
curl -X POST https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/notifications/recipients/[email protected]/resend \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/notifications/recipients/[email protected]/resend', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/notifications/recipients/[email protected]/resend",
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->post('forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/notifications/recipients/[email protected]/resend', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "form_notification_recipient",
"email": "[email protected]",
"status": "pending",
"confirmed_at": null,
"invited_at": "2026-10-24T15:10:00.000000Z"
}
#Send a test alert
POST/forms/{id}/notifications/test
Sends an alert with sample answers, using the saved settings, to the recipients who have already confirmed. Up to 5 tests per form every 10 minutes.
Path parameters
idstringrequiredForm ID.
Errors
- 422
validation_errorNo recipient has confirmed yet. - 429
rate_limit_exceededToo many tests. Try again in a few minutes. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X POST https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/notifications/test \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/notifications/test', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/notifications/test",
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->post('forms/019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d/notifications/test', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "form_notification_test",
"recipients": ["[email protected]"]
}
#Public submission
Submissions go to the forms host, without an API key: POST https://forms.postal.so/{slug}. The body can be a regular HTML form (application/x-www-form-urlencoded or multipart/form-data) or JSON. The field names are the ids of the blocks in pages (email, first_name… and your questions); list answers, such as multiple_choice, go as a list (JSON) or by repeating the name (interest[]).
| Rule | How it works |
|---|---|
| Bots | The _website field is a trap: leave it hidden and empty. If it arrives filled in, the response is a success, but nothing is stored. |
| Rate limit | 5 submissions per minute per IP address on each form. After that, 429. |
| Origin | With allowed_domains, only pages on those domains (and subdomains) can submit from a browser; the others get 403 origin_not_allowed. The hosted page always can. |
| CORS | Answers OPTIONS and allows POST with the Content-Type and Accept headers: for any origin, or only for the allowed_domains. It does not use cookies. |
| Language | The hosted page, the messages and the confirmation email follow the form language. |
| Paused or nonexistent form | 404. |
| Closed form | 410 with form_closed (JSON) when the form passed closes_at, reached max_submissions or was paused with a closed_message; the message is the closed_message text (as you wrote it) or, without it, "This form is no longer accepting answers." (or "Este formulário não está mais recebendo respostas.", depending on the form language). See Settings and closing. |
| Invalid answers | 422: a required answer left blank, a value outside the type or the options, an email address refused by validate_email or repeated with prevent_duplicates. Only the blocks visible on the path taken are checked. |
| Email address on the suppression list | The person sees the same success response, but no contact is created (and, with double opt-in, no confirmation email is sent). The submission is marked failed in List a form's submissions. |
HTML form
A regular form submission ends in a 303 redirect: to the redirect_url, if there is one (and no double opt-in), or to Postal's success screen (/{slug}/obrigado). If any field is wrong, the hosted page appears with the errors.
<form action="https://forms.postal.so/newsletter-k3x9p2qa" method="post">
<input type="email" name="email" placeholder="[email protected]" required>
<input type="text" name="first_name" placeholder="Your name">
<!-- Bot trap: it stays hidden and must arrive empty -->
<input type="text" name="_website" tabindex="-1" autocomplete="off" hidden>
<button type="submit">Subscribe</button>
</form>
JavaScript (JSON)
With Accept: application/json or a JSON body, the response is JSON: 201 with the texts to show on your page, or an error in the standard format. On 422, the extra errors field has the message for each field (the key is the block's id), in the form's language, and suggestion has the correction of an email address with a likely typo, when validate_email is on.
const response = await fetch('https://forms.postal.so/newsletter-k3x9p2qa', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ email: '[email protected]', first_name: 'Jane', interest: 'news', _website: '' }),
});
const body = await response.json();
if (response.ok) {
showMessage(body.title, body.message); // asks to confirm the email address with double opt-in
} else if (response.status === 410) {
showMessage(body.message); // form_closed: the form has closed
} else {
showError(body.message); // 422: body.errors has the error of each field
}
{
"object": "form_submission",
"id": "019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e",
"requires_confirmation": true,
"title": "Confirm your email",
"message": "We sent a link to your email. Click it to confirm your subscription.",
"redirect_url": null
}
{
"statusCode": 422,
"name": "validation_error",
"message": "Email: Enter a valid email address.",
"errors": {
"email": "Enter a valid email address."
},
"suggestion": null
}
{
"statusCode": 410,
"name": "form_closed",
"message": "Signups ended on Oct 31. Thank you for your interest!"
}
Form language
The hosted page, the messages Postal writes (validation, duplicates, closed or unavailable form, attempt limit), the default success and button texts and the double opt-in confirmation email come in pt-BR or en. Postal chooses in this order:
- the form's
settings.language, when it ispt-BRoren; - the
Accept-Languageof whoever opens or submits the form (the first ofpt-BRanden, in the header's order); pt-BR, when there is neither.
The confirmation email comes in the language the person saw the form in when submitting. What you wrote (titles, questions, the success message, closed_message) is never translated. To fix the language, send settings.language when you create or update the form.
When the contact joins the audience (right away or after confirming), Postal triggers the form.submitted webhook, in addition to contact.created or contact.updated. Anyone who subscribes through a form receives marketing emails again, even if they unsubscribed before.