PosthasteDocsGet an API key

Sending

Contacts and lists

An address book of your own users, grouped into lists, so you can tell them about an outage, a change to your terms or a release with one broadcast instead of a loop. It is for people who already use your product. It is not for newsletters, and it is not somewhere to put a list you bought.

Whether a contact can be mailed is the suppression list’s answer

A contact carries no “subscribed” flag of its own. suppressed and suppressionReasons are read from your suppression list every time you fetch a contact, so an unsubscribe or a hard bounce shows up the moment it happens and a contact can never disagree with what a send would actually do.

suppressed is true when a send to the address would be refused on some stream — an unsubscribe from announcements shows here even though the same person still receives your password resets. The reasons are the suppression reasons: hard_bounce, complaint, unsubscribe and the rest.

Deleting a contact does not remove a suppression. Deleting somebody who unsubscribed and importing them again next month must not be a way to mail them again, so it is not.

Fields are merge fields

fields is an object of your own values — a plan name, a region, a first name — that a broadcast can put into its content as {{plan}}. Alongside them every contact always has {{email}}, and {{name}} when it has a name. Those two win over a field of the same name.

RuleLimit
Field namesLetters, digits and underscores, starting with a letter or underscore, up to 64 characters — the same rule as a template variable, so every field is usable as one.
ValuesStrings only, up to 1,000 characters each. A number stored as JSON comes back as whatever JSON made of it, which is not what anybody typed into their CSV.
How manyAt most 50 fields per contact.
nameUp to 200 characters.

How the values are used at send time — and what happens when a contact is missing one — is on the broadcasts page.

Add a contact

POST/v1/contactscontacts:write
FieldTypeNotes
emailstring required3–320 characters. The address is the contact’s identity on your account.
consentstring requiredsigned_up, customer or imported_with_consent.
namestring optionalUp to 200 characters.
fieldsobject optionalString values, rules above.
listsstring[] optionalUp to 20 lst_ ids to put the new contact on.
const contact = await posthaste.contacts.create({
  email: user.email,
  name: user.name,
  fields: { plan: user.plan },
  // Where permission to mail this person came from. Required, no default.
  consent: 'signed_up',
})

contact.id // 'con_…'
contact.suppressed // read from the suppression list, not stored here

// An address already in your contacts is refused with 409 contact_exists,
// so a retry after a lost response cannot leave a duplicate behind.
StatusWhen
201The contact, in the shape above.
409 contact_existsThe address is already a contact. The error carries its id — update it with PATCH, or use import, which merges.
403 contact_limit_reachedYour plan’s contact limit is used. Carries limit and used.
404 not_foundOne of the lists does not exist on this account.

Import

POST/v1/contacts/importcontacts:write

Up to 1,000 rows per call, merged by address. One consent applies to the whole call, and an optional list puts every row on that list — new and existing contacts alike. The dashboard reads a CSV and sends it in chunks of this size.

FieldTypeNotes
contactsobject[] required1–1,000 rows of { email, name?, fields? }.
consentstring requiredRecorded on every contact this call creates.
liststring optionalA lst_ id.
// A thousand rows per call. Chunk a larger file and send the chunks in turn.
for (let i = 0; i < rows.length; i += 1000) {
  const result = await posthaste.contacts.import({
    consent: 'customer',
    list: listId,
    contacts: rows.slice(i, i + 1000),
  })
  // Indexes in `invalid` are into THIS chunk, not the whole file.
  report(i, result.invalid)
  if (result.overLimit > 0) break // the plan is full; the rest would be counted too
}
ResultMeaning
createdNew contacts.
updatedAddresses already in your book. A name given replaces the old one, fields merge over the old ones, and the consent record stands.
invalidRows that are not an email address, each with its index in the request. They never fail the other rows.
overLimitNew rows that did not fit under your plan’s contact limit. The limit applies to new contacts only, in row order; updating somebody already in the book never counts. When it is above zero, limitMessage says so in words.
suppressedHow many of the imported addresses are already on your suppression list. They are imported and counted, never dropped silently — a broadcast will skip them anyway, and you should know that forty of your users have bounced before.
addedToListContacts newly put on list; null when no list was given.

The same address twice in one call is one contact. The first spelling of the address stands, and a later row’s name and fields are added to it.

Read, change and delete

GET/v1/contactscontacts:read

Newest first, keyset-paginated — see pagination. limit is 1–200 (default 50), before is the nextCursor from the previous page, search matches anywhere in the address or the name, and list narrows to one list. total is every contact on the account whatever the filters — the number your plan counts.

curl -G https://api.posthastemail.dev/v1/contacts \
  -H "authorization: Bearer $POSTHASTE_KEY" \
  --data-urlencode "list=lst_AZLm3kQ8T2Sf9pXbNc7HrQ" \
  --data-urlencode "search=example.com" \
  --data-urlencode "limit=100"

# 200
# {
#   "data": [ { "id": "con_…", "email": "…", "suppressed": true,
#               "suppressionReasons": ["unsubscribe"], … } ],
#   "hasMore": false,
#   "nextCursor": null,
#   "total": 1840
# }
GET/v1/contacts/:idcontacts:read

One contact, with a lists array of the lists it is on.

PATCH/v1/contacts/:idcontacts:write

Changes name (send null to clear it) and fields. fields replaces the whole set — send the ones you want to keep. The address is the identity and cannot be changed: delete the contact and add the new address.

DELETE/v1/contacts/:idcontacts:write

204. Removes the contact from your book and from every list. It leaves the suppression list alone.

Lists

A list is a named group of contacts, and it is what a broadcast is sent to. A contact can be on any number of lists; deleting a list removes the grouping, never the contacts.

GET/v1/listscontacts:read
POST/v1/listscontacts:write
GET/v1/lists/:idcontacts:read
PATCH/v1/lists/:idcontacts:write
DELETE/v1/lists/:idcontacts:write
POST/v1/lists/:id/memberscontacts:write
DELETE/v1/lists/:id/members/:contactIdcontacts:write

A list has a name (1–80 characters, unique on your account ignoring case — a clash is 409 name_taken) and an optional description of up to 500. Every list comes back with its memberCount. GET /v1/lists returns them all, alphabetically, in one response.

Adding members takes up to 1,000 con_ ids and is idempotent: a contact already on the list is counted in alreadyMembers, and an id that names no contact on your account comes back in notFound rather than failing the others. Removing a member takes it off the list and leaves it in your book.

const list = await posthaste.lists.create({
  name: 'Status updates',
  description: 'Everyone on a paid plan',
})

// Existing contacts, by id. Idempotent: already-members are counted, not refused.
const { added, alreadyMembers, notFound } = await posthaste.lists.addMembers(list.id, [
  'con_Kd93Lm2PqR7tUv5WxY1zAb',
  'con_7Qw2Er4Ty6Ui8Op0As1Df3',
])

// Everybody on it, every page.
for await (const contact of posthaste.contacts.autoPaginate({ list: list.id })) {
  contact.email
}

The contacts:read and contacts:write scopes cover lists too.

Plan limits

How many contacts you may keep: Free 500, Starter 5,000, Growth 50,000, Scale 250,000, Enterprise unlimited. How many lists: Free 2, Starter 10, Growth 50, Scale unlimited, Enterprise unlimited. Past either, a create is 403 with contact_limit_reached or list_limit_reached, carrying the limit and how many you have; an import is not refused, and counts the rows that did not fit in overLimit. A list cannot hold more people than your book does, so there is no separate limit on recipients per broadcast. The full grid is in limits.

NextBroadcasts →An outage notice or a terms change to every contact on a list — each copy checked like a single send, never on the transactional stream, and stopped automatically if bounces or complaints climb.