PosthasteDocsGet an API key

Reference

Verify

Email somebody a one-time code, then ask us whether they typed it back correctly. The parts everybody rebuilds and gets wrong the same four ways — an attempt cap that survives concurrency, an expiry, a resend that does not extend it, and a code that never lands in a subject line — are already here.

Two calls

Start a verification, hold the id it returns, and check the code against that id. A verification is addressed by the id and never by recipient: an endpoint that took an address would answer questions about addresses the caller was never given, and would make “wrong code” and “no pending verification for this address” impossible to tell apart.

POST/v1/verificationsverifications:write
POST /v1/verifications
{
  "to": "[email protected]",
  "from": "[email protected]"
}

201 Created
{
  "id": "ver_AZLm…",
  "status": "pending",
  "messageId": "msg_AZLm…",
  "attemptsRemaining": 5,
  "sends": 1,
  "maxSends": 3,
  "expiresAt": "2026-09-03T11:10:00.000Z",
  "resendAfter": "2026-09-03T11:01:00.000Z"
}
POST/v1/verifications/:id/checkverifications:write
POST /v1/verifications/ver_AZLm…/check
{ "code": "318204" }

# Right
200 OK
{ "id": "ver_AZLm…", "status": "approved", "approvedAt": "…" }

# Wrong — still a 200. The request worked; the answer is no.
200 OK
{ "id": "ver_AZLm…", "status": "pending", "attemptsRemaining": 4 }

# Out of attempts, expired, cancelled or already used
409 Conflict
{ "error": { "type": "verification_max_attempts", "message": "…" } }

A wrong code is a 200

The request succeeded; the answer is no. Mistyping a digit is the most common outcome in the whole product, and every SDK we publish throws on a 4xx — so a 400 here would raise an exception on an ordinary event and force you to write your happy path inside a catch.

A 409 means terminal: expired, cancelled, already used, or out of attempts. Waiting cannot help and a new verification is the only way forward. Nothing here returns 429 except real rate limits, because a 429 is the one status a well-behaved client retries.

Running out of attempts burns the code. The correct one stops working too. A cap that only counted would be a speed bump — an attacker who exhausted it could simply keep going with the same live code.

Every refusal, and what to do about it

Eleven types, and the only two axes that matter are whether waiting helps and whether the verification can be salvaged. A 409 is terminal — the object is finished, and the way forward is a new one. A 429 is a clock: it carries retryAfter, and the same call works later. Every one of them arrives in the same envelope as the rest of the API, described in full on errors.

TypeStatusWhat happenedWhat to do
verification_max_attempts409Too many incorrect codes were submitted, so the verification is closed. The correct code no longer works either — the cap burns the code rather than merely counting it. Carries attempts and maxAttempts. A wrong code on a live verification is not this: that is a 200 with status: pending.Start a new verification.
verification_expired409The code passed its expiry. Resending does not extend it — a resend mails a fresh code on the original deadline.Start a new verification.
verification_already_used409The code was already approved. A code works once, deliberately.Start a new verification.
verification_canceled409The verification was cancelled, by you.Start a new verification.
verification_not_pending409Cancel found the verification already approved, expired or out of attempts — it was used, so there was nothing to call off. Cancelling one that is already cancelled is not this: that answers 200, because the state you asked for holds.Nothing. It is already over.
verification_not_resendable409The message the first code went out on is no longer available, so a resend has no sender address to reuse. Resends take the sender from that message rather than from the request, so a second code can never arrive under a different name than the first.Start a new verification.
resend_too_soon429A code was sent moments ago and the 60-second cooldown has not passed. Carries retryAfter.Wait retryAfter. Show it to the user as a countdown rather than a failed button.
resend_limit_reached409This verification has been sent the maximum number of times. Carries sends and maxSends. Not a throttle — waiting does not help.Start a new verification.
destination_throttled429Too many codes have been started for this address recently. Carries retryAfter. This is the limit that bounds a brute force: attempts cap guesses within one code, this caps how many codes can be opened at all.Wait retryAfter.
destination_locked429Three verifications to this address ran out of attempts within the hour, so it stops accepting new codes. Different from destination_throttled: that one means you have sent enough codes for now, this means the last three were guessed at until they burned.Wait it out — but read it first. This one is worth looking at the account for rather than the retry logic.
verification_limit_reached429The plan’s monthly verification allowance, a separate meter from the email allowance — a verification spends both. Carries limit and used.Next month, or upgrade. monthly_limit_reached is the other ceiling; see what it costs.

Nothing here needs a retry loop except the 429s. The four terminal states — expired, cancelled, already used, out of attempts — are the same branch in your code: tell the person the code is no longer good and offer to send another. Distinguishing them matters for your logs, not for your control flow.

In the SDKs

import { Posthaste } from '@posthaste/sdk';

const posthaste = new Posthaste({ apiKey: process.env.POSTHASTE_API_KEY! });

// 1. Send the code
const started = await posthaste.verifications.start({
  to: '[email protected]',
  from: '[email protected]',
});

// 2. Later, when they type it in
const result = await posthaste.verifications.check(started.id, '318204');

if (result.status === 'approved') {
  // sign them in
} else {
  // result.attemptsRemaining tells them how many guesses are left
}

The numbers, and what they buy

LimitDefaultWhy
Code length6 digitsDigits, not letters — they are what a phone keypad is for and they carry no 0/O ambiguity when read aloud. Ask for codeLength: 8 where the stakes justify it.
Attempts5Counted atomically, so twenty simultaneous guesses consume five attempts and close the verification rather than all reading zero and going through.
Expiry10 minutesNobody walks away from the screen they are typing a code into.
Resend60s apart, 3 totalEach resend mails a fresh code on the original deadline and the original attempt budget — so it is neither a way to hold a verification open forever nor a way to buy five more guesses.
Per address5 an hourThe limit that actually bounds a brute force. Attempts cap guesses within one code; this caps how many codes can be opened at all.
Failure lockout3 an hourThree verifications to one address running out of attempts closes that address to new codes for an hour — destination_locked. The throttle above bounds how many codes are sent; this bounds how many are guessed at.

Together: about a 0.4% chance of breaking one specific address over a week of relentless attack — and spending that budget costs the attacker 840 unrequested codes into the victim’s mailbox, which is the loudest signal available to both of you.

Choosing a length

Six digits by default. Send codeLength (4–8) on a single call, or set the account default once under settings — a per-request value always wins, so an integration can ask for eight on a payout without changing what an ordinary login gets.

LengthCodesBroken in a week of sustained attack
410,000~42%
5100,000~4%
61,000,000~0.4%
8100,000,000~0.004%

Four digits is not simply “a bit weaker”. The budget an attacker actually has is about 4,200 guesses a week against one address, so a four-digit code falls roughly two times in five. It is offered at all only because of the failure lockout above, which cuts that budget to fifteen guesses an hour and makes the attempt cost three unrequested codes in the victim’s mailbox.

Resend, cancel, read

POST/v1/verifications/:id/resendverifications:write
POST /v1/verifications/ver_AZLm…/resend
{ "to": "[email protected]" }

The address is required on a resend. We store only a keyed digest of the destination, and the resend matches on the digest you present — so an id on its own, even a leaked one, can never redirect somebody’s code to a new mailbox. The sender is taken from the message the first code went out on, so a second code cannot arrive under a different name than the one they already saw.

POST/v1/verifications/:id/cancelverifications:write

Cancelling twice is a 200 — this is the call most likely to be retried on a timeout, and the state you asked for already holds. Cancelling one that finished some other way is a 409, because there it was already used.

GET/v1/verifications/:idverifications:read

Status, attempts remaining and the id of the message the code went out on. It never returns the destination, because we never stored it.

Verifications are kept for 48 hours after they finish, then purged — the row holds a credential digest and nothing needs it after a day. This endpoint answers 404 for anything older. A verification is a short-lived object by contract.

Your name on it

PUT/v1/verifications/settingsverifications:write

Set a brand name and logo once and they apply to every code you send. There is one built-in template and no per-send content, deliberately: every knob that could vary the body is a way for a code mail to arrive with no code in it, and there is no worse failure on this particular message.

Design for the logo to be blocked. Most mail clients block images by default, so the brand name is rendered as text and the logo carries that name as its alt. A logo with no name set is dropped entirely rather than shown as an unlabelled broken image.

Logos are PNG, JPEG or WebP, up to 512 KB. The format is decided by reading the bytes, not by what you declare, and SVG is refused — it can carry script, and the file is served from our domain to your recipients.

What we do with the code

It goes out through the same pipeline as the rest of your mail: suppression-checked, warm-up governed, recorded on the delivery log and hash-chained like every other message.

Your own verified domain is required, and this is the one constraint on the endpoint worth explaining rather than asserting. A verification is the most phishable mail a platform sends: it is short, it is urgent, and its entire purpose is to get somebody to act on it without thinking. A code arriving from posthastemail.dev claiming to be your brand is precisely the shape of a phishing mail — a sender the reader has no relationship with, carrying somebody else’s name — and sending it would teach your users that such a message is normal, which is the habit every credential-phishing campaign relies on. So a code goes out under your own name or not at all.

Twilio’s email channel makes the same demand for the same reason, while its SMS channel will happily lend you a number: a phone number carries no brand claim, and a From domain does. In practice that means from is required on every start, domain verification is a prerequisite rather than a nicety, and a resend reuses the sender of the first message — so a second code can never arrive under a different name than the one they have already seen.

The code is never in the subject. Subjects are stored unsealed and kept for as long as your plan keeps delivery records; bodies are sealed and purged at 30 days. It is not in the preheader either, which is the line a locked phone shows on its lock screen.

The mail carries Auto-Submitted: auto-generated so it cannot start an auto-responder loop, and no List-Unsubscribe — you cannot opt out of your own login, and offering the header would put a real unsubscribe button on the one message that must always arrive.

What it costs

A verification spends two allowances: one email, and one against your plan’s separate monthlyVerifications cap. A resend spends both again; a check spends nothing, because metering a check would bill you for your user’s typing.

The two refusals are distinct so you are never left guessing which ceiling you met: monthly_limit_reached is the email allowance, verification_limit_reached is this one. Both figures are on GET /v1/usage.

NextMessages and the waybill →Read back a message, its status, its events and the SMTP conversation.