K-OTP

Idempotency & retries

Retry issue requests without duplicate sends or debits

After a network timeout or a transient error you sometimes need to send the same issue request again. K-OTP uses an idempotency key to process a request only once, so retries never send a second message or debit credits twice.

An idempotency key is required

POST /v1/issue cannot be called without an idempotency key. Send it in one of:

  • the Idempotency-Key request header
  • the idempotencyKey field of the request body
RuleResult
Neither header nor body key, or a blank key400 BAD_REQUEST
Both header and body key sent with different values400 BAD_REQUEST
Format: after trimming, 1–128 visible ASCII characters without spaces400 BAD_REQUEST otherwise

Create a new key for each "user asked for a code" action. A UUID (crypto.randomUUID()) is the simplest choice.

Sending the same key again

SituationResponse
Same key + same request bodyThe original response is replayed (200). Nothing is re-sent or re-debited.
Same key + different request body409 CONFLICT
Same key, but its issue has since been replaced by a newer issue409 CONFLICT
The earlier request with this key is still being resolved503 SERVICE_UNAVAILABLE — retry later with the same key.

A 409 will not go away on retry. Check whether the body changed, or create a new key if you really need a new issue.

Unknown outcomes: always retry with the same key

When you cannot tell whether the first request was processed — a timeout, a dropped connection, or a 503 — you must reuse the original idempotency key.

Never retry an unknown outcome with a new idempotency key. The first message may already have been sent, so a new key can cause a duplicate SMS and a duplicate debit.

When the SMS provider's outcome is unknown (for example a provider timeout), K-OTP records it as ambiguous and never re-sends automatically on the server side. You can see this as providerOutcomeAmbiguous: true in GET /v1/status. If the user did not receive the message, issue again with a new key only when the user explicitly asks to resend (the previous issue becomes replaced).

Retry example

const API = "https://api.k-otp.dev/v1";

class NonRetryableError extends Error {}

/** Retries only with the same idempotency key, and only on timeouts, network errors, 500 and 503. */
export async function issueWithRetry(
	input: { phoneNumber: string; purpose: string },
	idempotencyKey: string = crypto.randomUUID(),
	maxAttempts = 4,
) {
	for (let attempt = 1; ; attempt++) {
		try {
			const res = await fetch(`${API}/issue`, {
				method: "POST",
				headers: {
					Authorization: `Bearer ${process.env.KOTP_SECRET_KEY}`,
					"Content-Type": "application/json",
					"Idempotency-Key": idempotencyKey, // identical on every attempt
				},
				body: JSON.stringify(input), // identical on every attempt
				signal: AbortSignal.timeout(10_000),
			});
			const body = await res.json();
			if (res.ok)
				return body as {
					issueId: string;
					expiresAt: string;
					attemptsRemaining: number;
					queuedAt: string;
				};
			if (res.status !== 500 && res.status !== 503) {
				// 400, 401, 402, 403 and 409 return the same result on retry.
				throw new NonRetryableError(`${body.code}: ${body.message}`);
			}
		} catch (error) {
			if (error instanceof NonRetryableError) throw error;
			// Timeout or network error: the outcome is unknown, so retry with the same key.
		}
		if (attempt >= maxAttempts) {
			throw new Error(`issue outcome unknown; retry later with the same key: ${idempotencyKey}`);
		}
		await new Promise((r) => setTimeout(r, 2 ** attempt * 250));
	}
}

If every attempt fails, keep the idempotency key and retry later with it instead of discarding it.

Re-issuing and replacement

When you intentionally need a new code — for example the user tapped "resend code" — call POST /v1/issue with a new idempotency key. A new issue for the same phone number and the same purpose immediately marks the previous unverified issue as replaced, and it can no longer be verified. Verify with the new issueId only.

Verify requests

POST /v1/verify has no idempotency key. Verification is one-time, so verifying an already verified issue returns verified: false with reasonCode: "ALREADY_VERIFIED". If you get this after retrying a timed-out verify, the first request may have succeeded; check verificationStatus via GET /v1/status with an sk_ key if needed.

On this page