Email verification endpoints often look too small to deserve versioning. A user submits a code, the API marks an address as verified, and the request is done. In a real service, the same code can arrive twice, an old mobile client can keep calling the endpoint, and a retry can land after the first transaction committed.
That makes versioning more than a URL decision. It is a decision about which verification events are still valid, which responses are safe to replay, and which database state is authoritative. The contract should make those boundaries visible to engineers maintaining the service.
Why versioning verification is different
Most REST API changes can be handled by adding a field or accepting a broader input. Verification has a state transition with security meaning: pending becomes verified, and the transition may trigger a session, a welcome email, or an audit event.
If version 2 changes the meaning of an already-issued token, an old client may receive a valid-looking response for the wrong operation. It gets worse when the first request succeeds but the network drops before the client sees the response. In production, replay are normal; they are not an edge case.
I prefer versioning the request and response contract while keeping the state transition explicit. For example:
POST /v2/email-verifications/confirm
Idempotency-Key: signup-8c31-attempt-1
Content-Type: application/json
{
"verification_id": "ver_01J9Q2",
"code": "493812"
}
The idempotency key identifies the client operation, not the email address. A client retrying the same operation should receive the same outcome. A new attempt must use a new key, even when it targets the same account.
Define a replay-safe contract
Before adding a new endpoint version, write down the response for each repeatable case:
- The first valid confirmation returns
200withverified: true. - The same key and same request body returns the stored response.
- The same key with a different body returns
409because the operation identity was reused. - An expired or already-consumed code returns a stable domain error.
- A key from an older API version cannot silently invoke the new rules.
This is where a threat model for email verification helps. It forces the team to distinguish a harmless network retry from an attacker replaying a captured request. The response can be repeatable without making the verification token reusable.
Do not return the raw code, token, or full email address in a replayed response. The result should contain the minimum state the caller needs, such as verified, verification_id, and a bounded next action. The contract is safer when it reveals less.
Use PostgreSQL to enforce the boundary
Application checks alone are not enough when two Node.js workers confirm the same operation at nearly the same time. Put the uniqueness and transition rules in PostgreSQL so a race becomes a defined result rather than a log message.
A compact idempotency table can store the request fingerprint and the response that was committed:
CREATE TABLE verification_idempotency (
account_id bigint NOT NULL,
idempotency_key text NOT NULL,
request_hash bytea NOT NULL,
response jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, idempotency_key)
);
The transaction should lock the verification row, check the key, consume the code once, write the verification event, and store the response before commit. If the key exists, compare request_hash and return the stored response. If it differs, stop with a conflict. This order is a little more safer than trying to reconstruct a response after the fact.
Keep the email event separate from the verification state. An outbox row can be created in the same transaction, then a worker publishes it. That prevents a database commit from succeeding while the audit or welcome event disappears. The worker should also carry the API version that created the event, because consumers may migrate at a different speed.
Migrate clients without splitting behavior
A new URL does not need a new implementation of every rule. Keep the domain service shared and let each HTTP adapter translate its version-specific input into one internal command:
type ConfirmEmail = {
accountId: number;
verificationId: string;
code: string;
idempotencyKey: string;
apiVersion: 1 | 2;
};
Version 1 may preserve its old error shapes, while version 2 returns the cleaner contract. Both paths should call the same transactional state transition. Otherwise the two endpoints drift and a bug fix lands in only one of them.
During the migration, measure requests by version, client, and outcome. Do not use a broad user-agent guess. A header or explicit route makes the decision observable. If an old SDK sends an unexpected tem email value or a legacy test fixture contains tempail, record it as input-quality telemetry and reject it according to the documented schema.
Browser tests need their own timing budget; email timing budgets in browser tests are useful here. The API contract may be correct while a test still polls too briefly, or keeps polling after the verification is already terminal.
Operational checks before rollout
Before enabling version 2 for real traffic, I check:
- Duplicate requests produce one verification event and one outbox event.
- A reused idempotency key with changed input produces
409. - Expired codes cannot be revived by a retry.
- Old clients keep their documented response shape.
- Database locks and conflict rates are visible by endpoint version.
- Logs contain correlation IDs, but not codes or full addresses.
- The replay record has a retention policy and a bounded payload size.
The last point is easy to misss. Idempotency records are production data, not an infinite request cache. Expire them only after the client retry window and any operational investigation window you actually support.
Common questions
Should every API version have a separate table?
Usually no. Separate tables make migrations harder and can split the security rules. Version the external contract and store the version on events or audit records when the distinction matters.
Should an already-verified account return an error?
Not for a replay of the same successful operation. Return the stored success. For a new operation, return a stable already-verified result or conflict according to the documented contract. Consistency matter more than the exact status choice.
How long should idempotency records live?
Long enough to cover client retries, queue delays, and support investigation. Choose the retention from observed retry behavior, then enforce a size limit and cleanup job. Do not let a convenient default become a silent database tax.
Versioning email verification is mostly about preserving meaning under repetition. When the REST API, PostgreSQL transaction, and event consumers share an explicit contract, retries become ordinary behavior instead of a source of duplicate accounts and mysterious authentication bugs.
Top comments (0)