Skip to content

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#

  1. Create the form

    In the dashboard under Signup forms, or with POST /signup-forms. Pick a manual segment to add sign-ups to and, if the list is for one kind of email, a topic.

  2. 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.

  3. 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.created webhook and workflows.

There is no double opt-in

A submission is stored immediately; no confirmation email is sent. If you need one, build it with a workflow triggered by contact.created or contact.subscribed.

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. Use email, first_name and last_name for 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. select renders a dropdown of options.
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 with 422.

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.

POSThttps://api.poststack.dev/signup-forms
Request
// 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' }],
});
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 records subscribed: true for it, reversing an earlier opt-out.
success_messagestringdefault Thanks for subscribing!
Up to 500 characters.
redirect_urlstring
A URL to send the visitor to after a successful submission. Only http and https URLs are followed.

Response fields

signupFormobject
The new form.
signupForm.publicIdstring
The form id (sf_ + 24 characters), used as :id and 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

StatusMeaning
201Created. The body is { signupForm }.
400Body failed validation, e.g. fields.0.required: Invalid input… or an invalid redirect_url.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold signup-forms:manage (a sending-access key never does), or the team is suspended.
404Segment not found or Subscription topic not found.
429More than 30 requests to this endpoint in a minute from this key.

List signup forms#

Returns one page of forms, newest first.

GEThttps://api.poststack.dev/signup-forms
Request
const { data, meta } = await poststack.signupForms.list({ page: 1, per_page: 50 });
{
  "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

pageintegerdefault 1
Page number, from 1.
per_pageintegerdefault 20
1–100.

Response fields

dataobject[]
Forms, shaped as in Create a signup form.
meta.page / perPage / total / totalPagesinteger
Pagination.

Status codes

StatusMeaning
200The page of forms.
400per_page is above 100, or a value is not a positive integer.
401Missing, invalid or revoked API key.
403insufficient_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.

GEThttps://api.poststack.dev/signup-forms/:id
Request
const form = await poststack.signupForms.get('sf_m4n8b2v6c0x5z9a3s7d1f5g8');
{
  "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

StatusMeaning
200The form.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold signup-forms:manage (a sending-access key never does), or the team is suspended.
404Signup 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.

PATCHhttps://api.poststack.dev/signup-forms/:id
Request
const form = await poststack.signupForms.update('sf_m4n8b2v6c0x5z9a3s7d1f5g8', {
  active: false,
});
{
  "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, or null to stop adding sign-ups to a segment.
topic_idstring | null
A top_… id, or null to stop subscribing sign-ups to a topic.
success_messagestring
Up to 500 characters.
redirect_urlstring | null
A URL, or null to show the success message instead.
activeboolean
false makes every submission fail with 422 until you turn it back on.

Response fields

signupFormobject
The form after the update.

Status codes

StatusMeaning
200Updated.
400Body failed validation.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold signup-forms:manage (a sending-access key never does), or the team is suspended.
404Signup form not found, Segment not found or Subscription topic not found.
429More 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.

DELETEhttps://api.poststack.dev/signup-forms/:id
Request
await poststack.signupForms.delete('sf_m4n8b2v6c0x5z9a3s7d1f5g8');
{
  "success": true
}

Path parameters

idstringrequired
The form's publicId (sf_…).

Response fields

successboolean
Always true.

Status codes

StatusMeaning
200Deleted.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold signup-forms:manage (a sending-access key never does), or the team is suspended.
404Signup form not found.
429More 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.

POSThttps://api.poststack.dev/signup-forms/:id/submit
Request
// Server-side only — the browser needs no SDK and no key.
await poststack.signupForms.submit('sf_m4n8b2v6c0x5z9a3s7d1f5g8', {
  email: 'jane@example.com',
  first_name: 'Jane',
});
{
  "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 as true for 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

StatusMeaning
200Stored (JSON), or the success page (form post).
303Form post only: stored, redirecting to the form’s redirect URL.
400Missing or invalid email, a name longer than 100 characters, or a body that could not be parsed (Invalid request body).
404Form not found — wrong id, or the form was deleted.
422Form 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.
429More than 10 submissions to this form in a minute from one visitor IP.

What a submission does

New addressExisting contact
ContactCreated.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 flagfalse.Unchanged — signing up again does not undo an unsubscribe.
SegmentAdded.Added if not already a member.
Topicsubscribed: true.subscribed: true, reversing an earlier opt-out.
contact.created webhook and workflowsFired.Not fired.
contact.subscribed workflowsFired 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.

HTML
<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:

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.

Keep the spam trap

Bots fill every input they find. A human never sees the hidden _hp input, so a submission with a value there is treated as spam: it gets the normal success response, so the bot learns nothing, and nothing is stored.

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.

bash
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" }'

Next steps

Was this page helpful?

Related