Get an API key

Sending

Sending domains

We sign your mail as your domain, so you publish one record proving we are allowed to. Sending from an unverified domain is refused outright rather than queued and failed later.

What you publish

One record is required. The other two are returned for completeness and one of them is actively dangerous to paste over an existing value.

RecordStatusWhy
s1._domainkeyrequiredThe DKIM public key. The only record we require — DMARC passes when either SPF or DKIM aligns, and we sign as your domain, so DKIM alignment alone is sufficient. The value is longer than a single 255-character DNS string, so it is also returned pre-split as chunks for providers that need that.
_dmarcoptional recommendedYour policy for your own domain. Start at p=none and read two weeks of reports before tightening — going straight to p=reject silently blackholes legitimate mail. Only add it if you have none already.
SPFoptional not neededOur envelope sender is on our own bounce domain, so SPF is evaluated against us, not you. Your SPF record is never consulted for our mail.

Do not replace an existing SPF record. A domain may publish only one, and overwriting yours would break every other system that sends as you — your CRM, your invoicing, your helpdesk. Verification never asks for SPF and never fails because of it.

Add a domain

POST/v1/domainsdomains:write

Generates a fresh 2048-bit DKIM key pair for this domain and returns the records to publish. The private half is encrypted at rest and never leaves the platform.

FieldTypeNotes
namestring required3–253 characters, and must look like a domain — at least one dot, labels of a–z 0–9 - not starting or ending with a hyphen. Lower-cased on the way in.
const domain = await posthaste.domains.create({ name: 'yourdomain.com' })

domain.id      // 'dom_9pXbNc7HrQ8T2Sf3kQAZLm'
domain.status  // 'pending'

// One required record: the DKIM key. SPF and DMARC come back too, marked
// optional, with a warning apiece about overwriting an existing record.
const dkim = domain.records.find((r) => r.required)
console.log(dkim.type, dkim.name, dkim.value)
StatusWhen
201Created. Publish the records.
409 conflictThat domain is already on this account. Re-adding it consumes no new slot; fetch it from the list instead.
422 domain_limit_reachedYour plan includes fewer domains than you are trying to use. The body carries limit and used. Fixed by upgrading, not by retrying.
400 invalid_requestThe name is not a domain.

Verify it

POST/v1/domains/:id/verifydomains:write

Looks the records up in DNS now and records the outcome. It asks the zone’s own nameservers first and falls back to public resolvers, so no intermediate cache gets to decide whether you can send — a negative cache entry has previously reported a correctly published record as missing.

Every check is reported, passing or not, so you can see exactly which record is wrong instead of being told “verification failed”. Only checks with required: true gate the result.

const result = await posthaste.domains.verify('dom_9pXbNc7HrQ8T2Sf3kQAZLm')

// Branch on `verified`, not on the checks: SPF and DMARC are reported but
// neither of them failing stops the domain being usable.
if (!result.verified) {
  for (const check of result.checks) {
    console.log(check.record, check.status, check.detail)
  }
}
FieldNotes
statusverified or failed.
verifiedBoolean form of the same answer. This is the one to branch on.
checks[].recorddkim, spf or dmarc.
checks[].statuspass, fail (found, wrong) or not_found (nothing published).
checks[].detailPlain-language explanation, safe to show a customer verbatim.

A fresh record is often just not visible yet. DNS propagation takes a minute or two. A not_found immediately after publishing is normal — wait and call this again rather than re-publishing.

StatusWhen
200Verification ran. Read `verified`, not the status code.
404 not_foundNo such domain on this account.

List domains

GET/v1/domainsdomains:read

Newest first, not paginated — an account has a handful of domains. Each row carries the records to publish, so a browser tab closed before the DKIM value was copied is not a dead end. Everything returned is derived from stored public data; no private key is involved.

const { data } = await posthaste.domains.list()

for (const domain of data) {
  domain.status        // 'pending' | 'verified' | 'failed' | 'disabled'
  domain.messagesSent  // non-zero is what makes deleting it refused
  domain.records       // on the LIST too, so closing that first tab loses nothing
}
FieldNotes
statuspending, verified, failed or disabled. Only verified may send.
selectorWhich key signs this domain’s mail, so a rotation is visible.
messagesSentMessages ever sent from it — and what makes deleting it refused.
lastCheckedAtWhen the record was last looked for by the background sweep, so pending is not a mystery.

Remove a domain

DELETE/v1/domains/:iddomains:write

A domain that has never sent is deleted outright. One that has is refused, and that is a deliberate choice rather than a limitation: the delivery record is the product, and removing the domain would take its history with it. Stop sending from it instead — an unused domain costs nothing and keeps its records.

Inbound addresses on the domain go with it when the delete succeeds.

StatusWhen
204Deleted.
409 domain_in_useThe domain has messages on record. The body carries messageCount.
404 not_foundNo such domain, or the id is not a domain id.

Setup guidance

GET/v1/domains/:id/setupdomains:read

Answers “who hosts this domain’s DNS, and can we do this for them in one click”. It is what the dashboard’s setup screen calls so it can offer the right path instead of showing everybody a token field. Two live DNS lookups, so expect it to take a moment.

FieldNotes
hostThe identified DNS provider — id, name, url, note and the first four nameservers — or null if it could not be identified.
oneClick{ provider, url } when the provider supports Domain Connect and the template is available, otherwise null. Send the customer to url; they approve the change at their provider. When it is null, show them the manual records — a button that leads to a dead end is worse than no button.

Publish DKIM through Cloudflare

POST/v1/domains/:id/cloudflaredomains:write

If the domain is on Cloudflare, hand us a scoped API token and we will write the DKIM record for you. Exactly one record is written — SPF and DMARC are left alone on purpose, for the reason above.

FieldTypeNotes
tokenstring optionalA Cloudflare API token with Zone:DNS:Edit on this zone, 20–200 characters. May be omitted if you previously stored one with remember.
rememberboolean optionalDefault false. Stores the token, encrypted at rest under the same envelope as a DKIM private key, so adding a second domain needs no paste. Only honoured when a token was supplied on this request.

The token is verified before anything is stored, the zone is located by name, the record is written, and then normal verification runs. A response with verified: false usually just means propagation has not caught up.

FieldNotes
publishedWhat was written: [{ type, name, created }]. created is false when an existing record was updated.
zoneThe Cloudflare zone the record went into.
verifiedWhether the record was already visible in DNS when we checked.
notPublishedThe records we deliberately did not touch, each with a why — so the difference between “done for you” and “still yours to decide” is explicit.
StatusWhen
200Written. Read `verified` for whether it is live yet.
400 token_requiredNo token supplied and none stored.
400 cloudflare_token_invalidCloudflare rejected the token.
400 cloudflare_zone_not_foundThe token is valid but does not cover this zone.
400 cloudflare_write_failedThe write was refused — usually the token lacks Zone:DNS:Edit.
404 not_foundNo such domain on this account.

Forget a stored token

DELETE/v1/account/cloudflaredomains:write

Deletes the stored Cloudflare credential for the account. Always 204, whether one was stored or not — it is a statement about the end state, and reporting “there was nothing to delete” would tell a caller something about the account they may not have known.

NextSend an emailEvery field on POST /v1/emails, including replyTo and List-Unsubscribe.