Part of the PurelyManage series.
PurelyMail has an API. It is not publicly documented. There are no official client libraries. There is no changelog. What exists is a community-written reference at news.purelymail.com/api/index.html that covers the basic endpoints but leaves field names, response shapes, and edge cases as exercises for the reader.
Building PurelyManage meant figuring out every endpoint through trial, error, and reading raw JSON responses carefully. Some of it was quick. Some of it was not.
Silent Failures
The most frustrating category of problem was the silent failure: the API returns {"type": "success"} and does nothing.
This is worse than an error. An error tells you something is wrong. A success response with no effect tells you the request was accepted, leading you to assume the problem is on your side: a stale cache, a UI bug, a re-render issue. You spend time debugging things that are not broken before eventually tracing back to the API call itself.
Password change
The modifyUser endpoint accepts a payload to change various user settings. Changing a password seems like it should take a password field. It does not.
Sending { userName, password: "newpass" } returns success. The password does not change.
Sending { userName, passwordChangeKey: "newpass" } also returns success. The password also does not change.
The correct field is newPassword. This is not documented anywhere in the community reference. The only way to find it is to try every plausible field name until one of them actually works, which you can only verify by logging in with the new password.
// Does nothing:
await pmPost('/api/v0/modifyUser', { userName, password: newPassword })
// Also does nothing:
await pmPost('/api/v0/modifyUser', { userName, passwordChangeKey: newPassword })
// Actually works:
await pmPost('/api/v0/modifyUser', { userName, newPassword })
Domain field names
Several endpoints that operate on a domain take the domain name as a field. The field name is not consistent.
addDomain takes domainName. updateDomainSettings takes name. deleteDomain takes name. An early version of the backend sent domainName to updateDomainSettings and deleteDomain because that is what addDomain uses. Both returned success. Neither did anything.
// Wrong (silent failure on updateDomainSettings and deleteDomain):
await pmPost('/api/v0/updateDomainSettings', { domainName: name, recheckDns: true })
// Correct:
await pmPost('/api/v0/updateDomainSettings', { name, recheckDns: true })
Routing rules
The routing rule endpoints had several of these:
listRoutingRulesreturnsresult.rules, notresult.routingRulesdeleteRoutingRuletakesroutingRuleId, notidcreateRoutingRulerequiresprefix: falsein the payload. Without it, the rule is created but behaves differently than expected. This field is not mentioned in the community reference.- The domain field for creating a routing rule is
domainName, but listing them returns each rule’s domain as a plain string in a different position in the response object
Each one required reading the raw API response, comparing it to what the code expected, and adjusting.
The DKIM Debug
The DNS health feature shows the status of MX, SPF, DKIM, and DMARC records for each domain. Getting DKIM right took about 12 hours across two sessions.
PurelyMail requires three DKIM CNAME records per domain:
purelymail1._domainkey.yourdomain.com CNAME key1.dkimroot.purelymail.com
purelymail2._domainkey.yourdomain.com CNAME key2.dkimroot.purelymail.com
purelymail3._domainkey.yourdomain.com CNAME key3.dkimroot.purelymail.com
The first attempt used key1._domainkey.purelymail.com as the CNAME target, which looked plausible based on the naming pattern. It resolved. The DNS check still failed.
The second attempt reversed the subdomain structure, using purelymail1.key1.dkimroot.purelymail.com. Also resolved. Also failed.
The correct targets are key1.dkimroot.purelymail.com, key2.dkimroot.purelymail.com, key3.dkimroot.purelymail.com. The _domainkey part appears only on the left side of the CNAME record (the name on your domain), not on the right side (the PurelyMail target). This is easy to get backwards and the API gives you no feedback beyond a pass/fail boolean.
DMARC was simpler but also not documented explicitly:
_dmarc.yourdomain.com CNAME dmarcroot.purelymail.com
The full correct set of DNS records is:
yourdomain.com MX mailserver.purelymail.com
yourdomain.com TXT v=spf1 include:_spf.purelymail.com ~all
purelymail1._domainkey CNAME key1.dkimroot.purelymail.com
purelymail2._domainkey CNAME key2.dkimroot.purelymail.com
purelymail3._domainkey CNAME key3.dkimroot.purelymail.com
_dmarc CNAME dmarcroot.purelymail.com
DNS Recheck
There is no dedicated recheck endpoint. Triggering a DNS recheck is done by calling updateDomainSettings with { name, recheckDns: true }. The response to that call does not include the updated DNS status. To get the new status you have to call listDomains again afterward.
await pmPost('/api/v0/updateDomainSettings', { name, recheckDns: true })
const fresh = await pmPost('/api/v0/listDomains')
const domain = fresh.result.domains.find(d => d.name === name)
// domain.dnsSummary now has updated passesMx, passesSpf, passesDkim, passesDmarc
This is a two-call pattern to do one thing, but it works consistently.
App Passwords: A Dead End
The API has createAppPassword and deleteAppPassword endpoints. The panel was supposed to support managing app passwords per user.
createAppPassword consistently returned success. No app password appeared in the user’s account. Every field name variation was attempted: userName, user, email, name, description, label. Every combination returned {"type": "success"}. Nothing was ever created.
The feature was removed from the scope. The panel shows a note that app passwords must be managed directly in PurelyMail. This is the honest outcome when an API endpoint is completely opaque and trial-and-error produces no results.
What This Teaches
Building on an undocumented API is slower than it looks. Every endpoint needs to be tested end-to-end, not just until it returns success. Success responses prove only that the server accepted the request, not that anything happened.
A few practices that helped:
- Log raw responses. Always print the full response from the API during development, not just the parsed fields you expect. Unexpected fields in the response are often the clue you need.
- Verify with a second call. After a write operation, call a read endpoint to confirm the change actually took effect. Do not trust success responses alone.
- Test field name variations systematically. When a write does nothing, try
camelCase,snake_case, andPascalCasevariants. Try shorter and longer versions. Keep notes on what you tried. - Accept dead ends. Some things cannot be figured out without source access or official docs. App passwords was one of them. Spending another 10 hours on it would not have helped.
The PurelyMail API reference is a useful starting point but treat it as incomplete. Assume gaps and verify everything.