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.
Every contact says where consent came from
consent is required on every call that creates a contact, and there is no default. It is recorded on the contact as consentSource, with consentAt set to when it was first stored.
| consent | Meaning |
|---|---|
| signed_up | They created an account or signed up for your product themselves. The usual answer for users added from your own sign-up flow. |
| customer | They are a customer of yours — they bought or subscribed to something you sell. |
| imported_with_consent | They are neither of those in a system of yours, but gave you permission to contact them before you imported them. |
This is a statement you make to us, and the acceptable-use policy holds you to it. Purchased, rented and scraped lists are not allowed under any consent source, and a broadcast may not be used for marketing even to people who genuinely are your customers. See the acceptable-use policy.
Re-importing somebody who is already a contact keeps their original consent record. How a person came to be in your book does not change because a spreadsheet mentioned them again.
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.
| Rule | Limit |
|---|---|
| Field names | Letters, 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. |
| Values | Strings 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 many | At most 50 fields per contact. |
| name | Up 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
/v1/contactscontacts:write| Field | Type | Notes |
|---|---|---|
| string required | 3–320 characters. The address is the contact’s identity on your account. | |
| consent | string required | signed_up, customer or imported_with_consent. |
| name | string optional | Up to 200 characters. |
| fields | object optional | String values, rules above. |
| lists | string[] optional | Up 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.| Status | When |
|---|---|
| 201 | The contact, in the shape above. |
| 409 contact_exists | The address is already a contact. The error carries its id — update it with PATCH, or use import, which merges. |
| 403 contact_limit_reached | Your plan’s contact limit is used. Carries limit and used. |
| 404 not_found | One of the lists does not exist on this account. |
Import
/v1/contacts/importcontacts:writeUp 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.
| Field | Type | Notes |
|---|---|---|
| contacts | object[] required | 1–1,000 rows of { email, name?, fields? }. |
| consent | string required | Recorded on every contact this call creates. |
| list | string optional | A 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
}| Result | Meaning |
|---|---|
| created | New contacts. |
| updated | Addresses already in your book. A name given replaces the old one, fields merge over the old ones, and the consent record stands. |
| invalid | Rows that are not an email address, each with its index in the request. They never fail the other rows. |
| overLimit | New 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. |
| suppressed | How 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. |
| addedToList | Contacts 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
/v1/contactscontacts:readNewest 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
# }/v1/contacts/:idcontacts:readOne contact, with a lists array of the lists it is on.
/v1/contacts/:idcontacts:writeChanges 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.
/v1/contacts/:idcontacts:write204. 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.
/v1/listscontacts:read/v1/listscontacts:write/v1/lists/:idcontacts:read/v1/lists/:idcontacts:write/v1/lists/:idcontacts:write/v1/lists/:id/memberscontacts:write/v1/lists/:id/members/:contactIdcontacts:writeA 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.