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-Keyrequest header - the
idempotencyKeyfield of the request body
| Rule | Result |
|---|---|
| Neither header nor body key, or a blank key | 400 BAD_REQUEST |
| Both header and body key sent with different values | 400 BAD_REQUEST |
| Format: after trimming, 1–128 visible ASCII characters without spaces | 400 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
| Situation | Response |
|---|---|
| Same key + same request body | The original response is replayed (200). Nothing is re-sent or re-debited. |
| Same key + different request body | 409 CONFLICT |
| Same key, but its issue has since been replaced by a newer issue | 409 CONFLICT |
| The earlier request with this key is still being resolved | 503 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.