Signup forms
A form you put on your own website that adds visitors to your contacts — and optionally to a segment and a subscription topic — with no API key in the page.
How it works#
Create the form
Put it on your site
Copy the embed code from the form's page in the dashboard, or build your own form that posts to
/signup-forms/:id/submit. The submit endpoint needs no key and allows cross-origin requests from any site.Visitors sign up
Each submission creates the contact or updates the existing one with that address, adds it to the segment, subscribes it to the topic, and — for a new address — fires the
contact.createdwebhook and workflows.
The management endpoints live under https://api.poststack.dev/signup-forms and need the signup-forms:manage scope, which full-access API keys have.
Form fields#
fields describes the inputs the dashboard's embed code renders. The API does not enforce it at submit time: every submission must carry email, and nothing else is checked against the field list — a field's required flag becomes the HTML required attribute in the embed, not a server-side rule.
namestringrequired- 1–50 characters. The input's
name, and so the key it is submitted under. Useemail,first_nameandlast_namefor the built-in fields, or a contact property's name to fill that property. Any other name is ignored on submit. type"email" | "text" | "select"required- The input type.
selectrenders a dropdown ofoptions. requiredbooleanrequired- Must be present on every field — leaving it out is a
400. labelstringrequired- 1–100 characters. The visible label.
placeholderstring- Up to 200 characters. Not used for
select. optionsstring[]- The choices of a
select. For a select property, use the property's own options, or submissions are rejected with422.
Include an email field: the API does not require one in fields, but a form without it can never be submitted successfully.
Management endpoints#
Create a signup form#
Creates an active form.
https://api.poststack.dev/signup-forms// The SDK returns the form itself, not the { signupForm } envelope.
const form = await poststack.signupForms.create({
name: 'Newsletter',
segment_id: 'seg_r4t7y1u5i9o3p6a2s8d0f4g7',
fields: [{ name: 'email', type: 'email', required: true, label: 'Email' }],
});curl -X POST https://api.poststack.dev/signup-forms \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Newsletter",
"segment_id": "seg_r4t7y1u5i9o3p6a2s8d0f4g7",
"fields": [
{ "name": "email", "type": "email", "required": true, "label": "Email" }
]
}'{
"name": "Newsletter",
"segment_id": "seg_r4t7y1u5i9o3p6a2s8d0f4g7",
"topic_id": "top_a7s3d9f1g5h2j8k4l6q0w2e9",
"fields": [
{ "name": "email", "type": "email", "required": true, "label": "Email", "placeholder": "you@example.com" },
{ "name": "first_name", "type": "text", "required": false, "label": "First name" },
{ "name": "plan", "type": "select", "required": false, "label": "Plan", "options": ["free", "pro"] }
]
}HTTP/1.1 201 Created
{
"signupForm": {
"id": 12,
"teamId": 42,
"publicId": "sf_m4n8b2v6c0x5z9a3s7d1f5g8",
"name": "Newsletter",
"segmentId": 30,
"topicId": 3,
"fields": [
{ "name": "email", "type": "email", "required": true, "label": "Email", "placeholder": "you@example.com" },
{ "name": "first_name", "type": "text", "required": false, "label": "First name" },
{ "name": "plan", "type": "select", "required": false, "label": "Plan", "options": ["free", "pro"] }
],
"styling": null,
"successMessage": "Thanks for subscribing!",
"redirectUrl": null,
"honeypotField": "_hp",
"active": true,
"submissionCount": 0,
"createdAt": "2026-09-23T10:00:00.000Z",
"updatedAt": "2026-09-23T10:00:00.000Z"
}
}Body parameters
namestringrequired- 1–100 characters.
fieldsobject[]required- 1–10 field definitions:
{ name, type, required, label, placeholder?, options? }— see Form fields. segment_idstring- A segment's
seg_…id. Use a manual segment — a dynamic segment's members come from its rules, so adding sign-ups to it has no effect. topic_idstring- A topic's
top_…id. Every submission recordssubscribed: truefor it, reversing an earlier opt-out. success_messagestringdefaultThanks for subscribing!- Up to 500 characters.
redirect_urlstring- A URL to send the visitor to after a successful submission. Only
httpandhttpsURLs are followed.
Response fields
signupFormobject- The new form.
signupForm.publicIdstring- The form id (
sf_+ 24 characters), used as:idand in the submit URL. signupForm.id / teamIdinteger- Internal ids.
signupForm.namestring- Your name for the form. Not shown to visitors.
signupForm.segmentIdinteger | null- The target segment's internal numeric id — not its
seg_…id. signupForm.topicIdinteger | null- The topic's internal numeric id — not its
top_…id. signupForm.fieldsobject[]- The field definitions, as sent.
signupForm.stylingobject | null- Reserved; always null through the API.
signupForm.successMessagestring- Returned by the submit endpoint.
signupForm.redirectUrlstring | null- Where a successful submission sends the visitor.
signupForm.honeypotFieldstring- Name of the spam-trap input,
_hp. signupForm.activeboolean- Whether submissions are accepted.
signupForm.submissionCountinteger- Accepted submissions, including repeat ones from the same address. Spam-trap hits are not counted.
signupForm.createdAt / updatedAtstring (ISO 8601)- Timestamps.
Status codes
| Status | Meaning |
|---|---|
| 201 | Created. The body is { signupForm }. |
| 400 | Body failed validation, e.g. fields.0.required: Invalid input… or an invalid redirect_url. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold signup-forms:manage (a sending-access key never does), or the team is suspended. |
| 404 | Segment not found or Subscription topic not found. |
| 429 | More than 30 requests to this endpoint in a minute from this key. |
List signup forms#
Returns one page of forms, newest first.
https://api.poststack.dev/signup-formsconst { data, meta } = await poststack.signupForms.list({ page: 1, per_page: 50 });curl "https://api.poststack.dev/signup-forms?per_page=50" \
-H "Authorization: Bearer sk_live_..."{
"data": [
{
"id": 12,
"teamId": 42,
"publicId": "sf_m4n8b2v6c0x5z9a3s7d1f5g8",
"name": "Newsletter",
"segmentId": 30,
"topicId": 3,
"fields": [
{ "name": "email", "type": "email", "required": true, "label": "Email", "placeholder": "you@example.com" },
{ "name": "first_name", "type": "text", "required": false, "label": "First name" },
{ "name": "plan", "type": "select", "required": false, "label": "Plan", "options": ["free", "pro"] }
],
"styling": null,
"successMessage": "Thanks for subscribing!",
"redirectUrl": null,
"honeypotField": "_hp",
"active": true,
"submissionCount": 0,
"createdAt": "2026-09-23T10:00:00.000Z",
"updatedAt": "2026-09-23T10:00:00.000Z"
}
],
"meta": { "page": 1, "perPage": 20, "total": 1, "totalPages": 1 }
}Query parameters
pageintegerdefault1- Page number, from 1.
per_pageintegerdefault20- 1–100.
Response fields
dataobject[]- Forms, shaped as in Create a signup form.
meta.page / perPage / total / totalPagesinteger- Pagination.
Status codes
| Status | Meaning |
|---|---|
| 200 | The page of forms. |
| 400 | per_page is above 100, or a value is not a positive integer. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold signup-forms:manage (a sending-access key never does), or the team is suspended. |
Retrieve a signup form#
Returns one form.
https://api.poststack.dev/signup-forms/:idconst form = await poststack.signupForms.get('sf_m4n8b2v6c0x5z9a3s7d1f5g8');curl https://api.poststack.dev/signup-forms/sf_m4n8b2v6c0x5z9a3s7d1f5g8 \
-H "Authorization: Bearer sk_live_..."{
"signupForm": {
"id": 12,
"teamId": 42,
"publicId": "sf_m4n8b2v6c0x5z9a3s7d1f5g8",
"name": "Newsletter",
"segmentId": 30,
"topicId": 3,
"fields": [
{ "name": "email", "type": "email", "required": true, "label": "Email", "placeholder": "you@example.com" },
{ "name": "first_name", "type": "text", "required": false, "label": "First name" },
{ "name": "plan", "type": "select", "required": false, "label": "Plan", "options": ["free", "pro"] }
],
"styling": null,
"successMessage": "Thanks for subscribing!",
"redirectUrl": null,
"honeypotField": "_hp",
"active": true,
"submissionCount": 0,
"createdAt": "2026-09-23T10:00:00.000Z",
"updatedAt": "2026-09-23T10:00:00.000Z"
}
}Path parameters
idstringrequired- The form's
publicId(sf_…).
Response fields
signupFormobject- The form.
Status codes
| Status | Meaning |
|---|---|
| 200 | The form. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold signup-forms:manage (a sending-access key never does), or the team is suspended. |
| 404 | Signup form not found. |
Update a signup form#
Changes only what you send. Takes effect for the next submission; embed code already on your site is not updated, so re-copy it after changing fields.
https://api.poststack.dev/signup-forms/:idconst form = await poststack.signupForms.update('sf_m4n8b2v6c0x5z9a3s7d1f5g8', {
active: false,
});curl -X PATCH https://api.poststack.dev/signup-forms/sf_m4n8b2v6c0x5z9a3s7d1f5g8 \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "active": false }'{
"active": false,
"redirect_url": null
}{
"signupForm": {
"id": 12,
"teamId": 42,
"publicId": "sf_m4n8b2v6c0x5z9a3s7d1f5g8",
"name": "Newsletter",
"segmentId": 30,
"topicId": 3,
"fields": [
{ "name": "email", "type": "email", "required": true, "label": "Email", "placeholder": "you@example.com" },
{ "name": "first_name", "type": "text", "required": false, "label": "First name" },
{ "name": "plan", "type": "select", "required": false, "label": "Plan", "options": ["free", "pro"] }
],
"styling": null,
"successMessage": "Thanks for subscribing!",
"redirectUrl": null,
"honeypotField": "_hp",
"active": false,
"submissionCount": 0,
"createdAt": "2026-09-23T10:00:00.000Z",
"updatedAt": "2026-09-25T15:30:00.000Z"
}
}Path parameters
idstringrequired- The form's
publicId(sf_…).
Body parameters
namestring- 1–100 characters.
fieldsobject[]- Replaces the whole list. 1–10 field definitions:
{ name, type, required, label, placeholder?, options? }— see Form fields. segment_idstring | null- A
seg_…id, ornullto stop adding sign-ups to a segment. topic_idstring | null- A
top_…id, ornullto stop subscribing sign-ups to a topic. success_messagestring- Up to 500 characters.
redirect_urlstring | null- A URL, or
nullto show the success message instead. activebooleanfalsemakes every submission fail with422until you turn it back on.
Response fields
signupFormobject- The form after the update.
Status codes
| Status | Meaning |
|---|---|
| 200 | Updated. |
| 400 | Body failed validation. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold signup-forms:manage (a sending-access key never does), or the team is suspended. |
| 404 | Signup form not found, Segment not found or Subscription topic not found. |
| 429 | More than 30 requests to this endpoint in a minute from this key. |
Delete a signup form#
Deletes the form. Submissions to it fail with 404 from then on; contacts it already collected are kept. To pause a form instead, set active to false.
https://api.poststack.dev/signup-forms/:idawait poststack.signupForms.delete('sf_m4n8b2v6c0x5z9a3s7d1f5g8');curl -X DELETE https://api.poststack.dev/signup-forms/sf_m4n8b2v6c0x5z9a3s7d1f5g8 \
-H "Authorization: Bearer sk_live_..."{
"success": true
}Path parameters
idstringrequired- The form's
publicId(sf_…).
Response fields
successboolean- Always true.
Status codes
| Status | Meaning |
|---|---|
| 200 | Deleted. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold signup-forms:manage (a sending-access key never does), or the team is suspended. |
| 404 | Signup form not found. |
| 429 | More than 30 requests to this endpoint in a minute from this key. |
Submit endpoint#
Submit a signup form#
Public: no API key, CORS open to every origin. Accepts JSON, or a plain HTML form post (application/x-www-form-urlencoded or multipart/form-data; file inputs are ignored). JSON callers get JSON back; form posts get a redirect or a minimal HTML page.
https://api.poststack.dev/signup-forms/:id/submit// Server-side only — the browser needs no SDK and no key.
await poststack.signupForms.submit('sf_m4n8b2v6c0x5z9a3s7d1f5g8', {
email: 'jane@example.com',
first_name: 'Jane',
});curl -X POST https://api.poststack.dev/signup-forms/sf_m4n8b2v6c0x5z9a3s7d1f5g8/submit \
-H "Content-Type: application/json" \
-d '{ "email": "jane@example.com", "first_name": "Jane" }'{
"email": "jane@example.com",
"first_name": "Jane",
"plan": "pro"
}{
"success": true,
"message": "Thanks for subscribing!"
}Path parameters
idstringrequired- The form's
publicId(sf_…).
Body parameters
emailstringrequired- A valid address. Lower-cased and trimmed; identifies the contact.
first_namestring- Up to 100 characters. Overwrites the stored first name when sent — an empty string blanks it; left out, it is kept.
last_namestring- Up to 100 characters. Same rule as first_name.
<property name>string- Any key equal to a contact property's name is saved to it, converted to its type; up to 1,000 characters. An empty value is skipped rather than clearing the stored one, and a checkbox's
"on"counts astruefor a boolean property. Keys that match no property are ignored. _hpstring- The spam trap. Leave it empty; a submission with any value here is answered with success and discarded.
Response fields
successboolean- true.
messagestring- The form’s success message.
redirect_urlstring- Present when the form has a redirect URL. Your script should navigate there.
Status codes
| Status | Meaning |
|---|---|
| 200 | Stored (JSON), or the success page (form post). |
| 303 | Form post only: stored, redirecting to the form’s redirect URL. |
| 400 | Missing or invalid email, a name longer than 100 characters, or a body that could not be parsed (Invalid request body). |
| 404 | Form not found — wrong id, or the form was deleted. |
| 422 | Form is not active; a property value of the wrong type (Invalid value for property "plan": must be one of free, pro); or a value over 1,000 characters. |
| 429 | More than 10 submissions to this form in a minute from one visitor IP. |
What a submission does
| New address | Existing contact | |
|---|---|---|
| Contact | Created. | Updated: a sent first_name / last_name overwrites the stored one (an empty input blanks it), sent properties are merged in, everything else is kept. |
| unsubscribed flag | false. | Unchanged — signing up again does not undo an unsubscribe. |
| Segment | Added. | Added if not already a member. |
| Topic | subscribed: true. | subscribed: true, reversing an earlier opt-out. |
| contact.created webhook and workflows | Fired. | Not fired. |
| contact.subscribed workflows | Fired when the form has a topic. | Fired when the form has a topic. |
| submissionCount | +1 | +1 |
Required contact properties are not enforced on this endpoint, so adding one never breaks a form already on a live site.
Embedding the form#
The dashboard's embed code is a real HTML form, so it works with JavaScript off. The optional script posts the same fields as JSON and shows the result in place. Replace the form id with yours.
<form id="poststack-form" action="https://api.poststack.dev/signup-forms/sf_m4n8b2v6c0x5z9a3s7d1f5g8/submit" method="post">
<label for="ps-email">Email</label>
<input id="ps-email" type="email" name="email" required />
<label for="ps-first_name">First name</label>
<input id="ps-first_name" type="text" name="first_name" />
<!-- Spam trap: leave empty and hidden -->
<input type="text" name="_hp" value="" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true" />
<button type="submit">Subscribe</button>
<p data-poststack-message role="status"></p>
</form>
<!-- Optional: submit without leaving the page. Without it the form still works. -->
<script>
document.getElementById('poststack-form').addEventListener('submit', async (event) => {
event.preventDefault();
const form = event.currentTarget;
const message = form.querySelector('[data-poststack-message]');
const res = await fetch(form.action, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
const data = await res.json();
if (data.redirect_url) return window.location.assign(data.redirect_url);
message.textContent = res.ok ? data.message : data.error;
if (res.ok) form.reset();
});
</script>Or post from your own JavaScript:
const res = await fetch('https://api.poststack.dev/signup-forms/sf_m4n8b2v6c0x5z9a3s7d1f5g8/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'jane@example.com', first_name: 'Jane' }),
});
const data = await res.json();
// 200: { success: true, message, redirect_url? } — otherwise { error, code }A form post with JavaScript off lands on the API host: a 303 to your redirect_url when the form has one, otherwise a plain page showing the success message. Errors are shown the same way, as a plain page with the reason. Set a redirect_url if you want visitors to stay on your own site.
Troubleshooting#
Every submission returns 422 Form is not active
The form was switched off. Set active to true with PATCH /signup-forms/:id or on the form's page in the dashboard.
A field the form collects is not on the contact
Only email, first_name, last_name and keys matching a contact property's name are stored. Create a property with the input's exact name.
Sign-ups do not appear in the segment
The segment is dynamic, so its members come only from its rules. Point the form at a manual segment, or add a rule that matches the new contacts.
Submitting from my site gets 429
The limit is 10 submissions per form per minute from one IP address. It is there to stop abuse; if you are testing, wait a minute.
Submitting from your own backend#
If you already collect sign-ups on your server, create contacts with the Contacts API instead — it is authenticated, returns the contact, and is not limited per visitor IP. The submit endpoint is designed for browsers.
curl -X POST https://api.poststack.dev/contacts \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "email": "jane@example.com", "first_name": "Jane" }'